diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index b99a5c97..d9accfee 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -179,6 +179,8 @@ src/ ISelectionService.cs -> done AcDream.App/ Layer 1 + Layer 4 wiring + Physics/ + ProjectileController.cs -> live-record projectile orchestration/corrections Rendering/ GameWindow.cs -> still owns too much runtime wiring TerrainRenderer.cs -> done @@ -224,6 +226,28 @@ What exists and is active: AlignPath, Setup-sphere transition sweeps, and collision response. It owns no live entity, network, rendering, or App state; the App controller supplies those boundaries. +- `ProjectileController` is that App boundary. It installs one body on the + canonical `LiveEntityRecord`, advances it on the update/render thread, + commits through `WorldEntity.SetPosition`, publishes the effect root, + synchronizes the existing shadow, and calls `RebucketLiveEntity` only when + the full cell changes. Pending/leave-world residence suspends the shadow's + cell rows while retaining its registration for exact re-entry. Timestamp- + gated State/Vector/Position/Movement packets correct or stop that same body; + every production MovementManager delegates full-cell reads/writes to the + incarnation-scoped live record, and projectile acquisition adopts its body + without replaying vectors already installed when that body was created, + regardless of component creation order. Accepted SetState flags reach that + canonical body before optional projectile acquisition, so absent/unsupported + DAT shape data cannot leave collision state stale; a non-finite local receipt + clock uses the controller's last finite game-clock value and cannot consume + first-time Missile classification. Shared-body acquisition validates the + adopted body's current frame/vectors, not obsolete CreateObject inputs. + Initial and late Setup resolution share GameWindow's `_datLock`, preserving + the single-reader `DatCollection` invariant while streaming is active. + Generic remote translation is suppressed while projectile integration + owns the frame; + delete, generation replacement, pickup/parent leave-world, and session reset + use `LiveEntityRuntime`'s normal lifecycle and never create a second GUID map. - `BSPQuery` contains the partial retail-style BSP collision dispatcher used by the transition path. - `TransitionTypes` carries `SpherePath`, `CollisionInfo`, `ObjectInfo`, diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 00f73346..d13731d3 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -516,6 +516,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - ~~**D.8 — Sound.**~~ **Superseded — shipped as Phase E.2** (`SoundTable`/`Sound` dat decode, OpenAL 16-voice engine, per-entity 3D positional audio via `AudioHookSink`). Entry kept here for history; see the shipped table. - **Missile/portal VFX campaign Step 6 implemented and independently reviewed 2026-07-14.** Pure Core now owns the retail projectile clock/integration/sweep primitive: 0.2-second catch-up quanta, 50-unit/second clamp, final-state acceleration, world-space omega, complete 3-D AlignPath, scaled Setup-local collision spheres, terrain/BSP/object continuous sweeps, exact `OBJECTINFO::missile_ignore`, self-shadow rejection, elastic/inelastic response, and correct full-cell rebasing across loaded landblocks. App live ownership and authoritative corrections remain Step 7. TS-2 is retired; ordinary missiles add PathClipped but not PerfectClip, so AP-83/AP-91 remain dormant. +- **Missile/portal VFX campaign Step 7 implemented and independently reviewed 2026-07-14.** `ProjectileController` now attaches the Step 6 body to the canonical `LiveEntityRecord` after materialization and advances arrows, bolts, and spell missiles without another GUID map, renderer registration, or stale-spawn reconstruction. It commits predicted frames through `WorldEntity.SetPosition`, republishes static effect roots, and keeps collision shadows plus canonical full-cell buckets synchronized. A predicted crossing into an unloaded bucket suspends the body and shadow in that same quantum; pending/pickup residence retains the shadow registration for exact re-entry, and hydration plus the 96-unit retail activity gate restart at the current clock without backlog. Fresh State/Vector/Position/Movement packets correct or stop the same body after retail timestamp gates; SetState reaches the canonical body before optional projectile acquisition, and a non-finite local receipt clock cannot consume first classification. Removing Missile retains ordinary active-body physics (delegating to MovementManager when present), while both owners share the body and incarnation-scoped live-record cell. CreateObject vectors are installed only at body construction, never replayed by later SetState. Delete, reset, and GUID reuse use normal logical teardown; ACE remains authoritative for impact, damage, effects, and deletion. **Reference docs:** `docs/research/retail-ui/00-master-synthesis.md` + slices 01-06. Every AC-specific behavior has a decompiled FUN_ / DAT_ citation. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index b14247ed..7e63bcd4 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -471,7 +471,7 @@ include dungeons. `PlayerDescription.Options1` preserves retail's player secure-trade preference. See `docs/research/2026-07-13-retail-give-item-pseudocode.md` and issue #216. -- **M2/M3 missile, effect, and portal presentation campaign (Steps 0–6 +- **M2/M3 missile, effect, and portal presentation campaign (Steps 0–7 implemented and independently reviewed 2026-07-14)** — named-retail projectile and physics-script behavior is pinned in one oracle, the complete `PhysicsDesc` and F754/F755 wire surfaces are parsed with retail timestamp gates, and @@ -515,9 +515,19 @@ include dungeons. Setup-local sphere sweeps, exact missile target/ethereal exclusions, self-shadow rejection, retail collision response, and full-cell landblock rebasing. Missile adds PathClipped but never PerfectClip. AP-67, TS-2, TS-10, - TS-11, and TS-12 are retired; AP-83/AP-91 remain dormant. The remaining - steps integrate live projectile ownership/corrections and port Hidden/UnHide - portal presentation. + TS-11, and TS-12 are retired; AP-83/AP-91 remain dormant. Step 7 attaches + that primitive to the canonical `LiveEntityRecord`: arrows, bolts, and spell + missiles share one body and one renderer identity across prediction, + authoritative State/Vector/Position/Movement corrections, full-cell + rebucketing, pending-landblock residence, delete, session reset, and GUID + reuse. MovementManager presentation shares the same body and the canonical + live-record cell regardless of which component was created first; + pending/leave-world shadows retain their registration for exact re-entry even + if Missile classification changes while pending; static missile effect + roots follow every predicted frame, and materialized rehydration never uses a + stale spawn cell. No impact, damage, effect, or delete event is synthesized + client-side. The remaining steps port + Hidden/UnHide portal presentation and run final hardening/visual gates. - **L.1c local attack receive path (implemented 2026-07-11; live gate pending)** — local non-autonomous mt-0 UpdateMotion now uses retail's wholesale interpreted funnel and action-stamp gate, so ACE's server-selected melee/missile action diff --git a/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md b/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md index b7e44a47..f90ad6af 100644 --- a/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md +++ b/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md @@ -872,3 +872,105 @@ observable ordering without exposing a half-committed pose to modern sinks. Boundary conformance covers positive and negative X and Y crossings. - TS-2 is retired. AP-83 and AP-91 remain because ordinary missiles are PathClipped and do not arm their dormant PerfectClip time-of-impact paths. + +## 16. Step 7 live integration map (2026-07-14) + +The App integration preserves the same retail object across every spatial and +network operation: + +```text +after materialized CreateObject: + if final PhysicsState has never carried Missile: no projectile component + if Setup does not have the audited one ordinary sphere: diagnose, no guess + otherwise: + construct one PhysicsBody, or adopt the same body already owned by the + object's MovementManager + validate the inbound frame/vectors, canonicalize the outdoor cell, + retain valid friction in [0,1], and clamp elasticity to [0,0.1] + apply velocity through set_velocity (immediate 50-unit/second clamp) + bind it to LiveEntityRecord.ProjectileRuntime + +when MovementManager and projectile classification arrive in either order: + bind MovementManager cell reads/writes to this exact live-record incarnation + share its PhysicsBody with ProjectileRuntime + install CreateObject velocity/omega when the ordinary body is constructed + if projectile classification arrives second, preserve that body's CURRENT + vectors/Active state, validate that current body rather than stale + CreateObject vectors, normalize its current cell frame, and translate + its clock to the App game clock without replaying retained vectors; + a safely ignored malformed CreateObject vector cannot veto a later + finite body, while a non-finite current body cannot enter simulation + +each frame: + for each canonical live record with a projectile component: + skip/clear Active through update_object when parented, cell-less, + Frozen, Static, or out of world + if the object has a PartArray, clear Active beyond the retail + 96-world-unit player-distance boundary; reactivation stamps now + run ProjectilePhysicsStepper with local WorldEntity.Id for self-shadow + exclusion and target id zero + if this exact record generation still owns this exact component: + WorldEntity.SetPosition(resolved body frame) + update orientation and full parent cell + RebucketLiveEntity only when the full cell changed + move the existing collision shadow to the resolved frame + publish the new root to the effect-pose owner + if Missile is now clear and MovementManager exists: that ordinary owner + advances the shared body instead + if Missile is clear with no MovementManager: keep advancing the active + retained CPhysicsObj; collision reads final state, so missile_ignore + and Missile-derived PathClipped are disabled automatically + +fresh State/Vector/Position/Movement packet: + reject structurally invalid Position/Vector values before advancing the + snapshot or timestamp, even if Missile classification has not arrived + first pass the existing per-channel retail timestamp gate + State: update the canonical body first, even when missile acquisition is + impossible because Setup or sphere data is unavailable; the accepted + SetState collision flags must never remain only on the live record. + A non-finite local receipt clock does not discard first classification: + bind against the controller's last finite game clock (the clock epoch + is zero before the first frame), while keeping the wire state authoritative. + Update this body without newly activating it; removing Missile + disables missile classification but retains the component/body on this + object. MovementManager becomes the update owner when present; + otherwise the retained active CPhysicsObj continues ordinary physics. + A later Missile state reuses that same current body and cell; + align the projectile clock at that ownership transfer, preserve the + existing Active bit, and never activate a body that was already inactive + Vector: set_velocity + omega on this body; clamp immediately and stamp the + receipt clock only when the packet reactivates an inactive object; if + Missile is currently clear and MovementManager exists, its ordinary + Airborne/LeaveGround funnel owns those writes on the shared body + Position: hard-correct world frame plus canonical wire cell-local frame, + shadow, spatial bucket, and effect pose; do not overwrite velocity + Movement: unpack through the normal MovementManager/animation funnel while + sharing this exact PhysicsBody and the live record's canonical cell; + projectile stepping suppresses the generic remote translation tick, + so there is still only one integrator and one spatial authority + +leave_world (pickup/parent): + clear InWorld and Active, withdraw collision shadows but retain their exact + registration payload and the component with the incarnation + fresh Position re-entry restores the same shadows, stamps the current + physics clock, and never consumes the time spent outside the world + +loaded/pending transition: + the rebucket result is checked in the same physics quantum + pending clears InWorld/Active and suspends shadows without teardown + hydration restores body/shadow membership even if Missile was cleared while + pending; projectile integration itself remains disabled + landblock rehydration keys materialized movers by LiveEntityRecord.FullCellId, + never by the retained create-time Snapshot.Position + +DeleteObject / generation replacement / session reset: + normal LiveEntityRuntime teardown destroys the owning record; the + projectile controller has no second GUID dictionary to clear +``` + +`ProjectileController` runs before animation advancement in the render/update +phase and publishes its current root directly; an animated projectile then +publishes final part poses later in the same frame. PhysicsScript/particle/light +ticks therefore observe the projectile's current predicted root. The controller never +creates impact effects, collision messages, damage, or deletion; ACE's later +packets remain authoritative for every gameplay outcome. diff --git a/src/AcDream.App/Physics/ProjectileController.cs b/src/AcDream.App/Physics/ProjectileController.cs new file mode 100644 index 00000000..f0752797 --- /dev/null +++ b/src/AcDream.App/Physics/ProjectileController.cs @@ -0,0 +1,744 @@ +using System.Numerics; +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Physics; + +/// +/// Update-thread presentation owner for live retail missiles. Logical identity, +/// generation, accepted network state, and teardown remain owned by +/// ; this controller only advances the +/// attached to that canonical record and commits its +/// frame through the existing projection. +/// +/// +/// Retail anchors are CPhysicsObj::update_object 0x00515D10, +/// CPhysicsObj::UpdateObjectInternal 0x005156B0, and +/// CPhysicsObj::SetPositionInternal 0x00515330. The pure +/// integration/sweep is implemented by . +/// ACE remains authoritative for collision outcomes, damage, effects, and +/// deletion; no such events are synthesized here. +/// +internal sealed class ProjectileController +{ + internal sealed class Runtime : ILiveEntityProjectileRuntime + { + internal Runtime( + ushort generation, + PhysicsBody body, + ProjectileCollisionSphere collisionSphere, + bool hasPartArray) + { + Generation = generation; + Body = body; + CollisionSphere = collisionSphere; + HasPartArray = hasPartArray; + } + + internal ushort Generation { get; } + public PhysicsBody Body { get; } + internal ProjectileCollisionSphere CollisionSphere { get; } + internal bool HasPartArray { get; } + } + + private readonly LiveEntityRuntime _liveEntities; + private readonly ProjectilePhysicsStepper _stepper; + private readonly ShadowObjectRegistry _shadows; + private readonly Func _setupResolver; + private readonly Action _publishRootPose; + private double _lastFiniteGameTime; + + internal ProjectileController( + LiveEntityRuntime liveEntities, + PhysicsEngine physicsEngine, + Func? setupResolver = null, + Action? publishRootPose = null) + { + _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); + ArgumentNullException.ThrowIfNull(physicsEngine); + _stepper = new ProjectilePhysicsStepper(physicsEngine); + _shadows = physicsEngine.ShadowObjects; + _setupResolver = setupResolver ?? (_ => null); + _publishRootPose = publishRootPose ?? (_ => { }); + } + + internal Action? DiagnosticSink { get; set; } + + internal int Count => _liveEntities.Records.Count( + static record => record.ProjectileRuntime is Runtime); + + internal bool CanAcceptVectorPayload( + uint serverGuid, + Vector3 velocity, + Vector3 angularVelocity) + { + bool valid = IsFinite(velocity) && IsFinite(angularVelocity); + if (!valid) + DiagnosticSink?.Invoke( + $"Rejected invalid VectorUpdate for live object 0x{serverGuid:X8}."); + return valid; + } + + internal bool CanAcceptPositionPayload( + uint serverGuid, + CreateObject.ServerPosition position, + Vector3? velocity) + { + var origin = new Vector3( + position.PositionX, + position.PositionY, + position.PositionZ); + var orientation = new Quaternion( + position.RotationX, + position.RotationY, + position.RotationZ, + position.RotationW); + bool valid = IsFinite(origin) + && PositionFrameValidation.IsValid( + position.LandblockId, + origin, + orientation) + && (velocity is null || IsFinite(velocity.Value)); + if (!valid) + DiagnosticSink?.Invoke( + $"Rejected invalid PositionUpdate for live object 0x{serverGuid:X8}."); + return valid; + } + + /// + /// Installs a projectile body once, after the live record and render + /// projection exist. Re-entry returns the same component and never replays + /// logical registration or create-time effects. + /// + internal bool TryBind( + LiveEntityRecord record, + Setup setup, + double currentTime, + int liveCenterX = 0, + int liveCenterY = 0) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(setup); + + if ((record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0) + return false; + + if (record.ProjectileRuntime is Runtime retained) + { + retained.Body.State = record.FinalPhysicsState; + return true; + } + + if (record.WorldEntity is not { } entity + || record.Snapshot.Physics is not { } physics + || record.Snapshot.Position is not { } wirePosition) + return false; + + float scale = physics.Scale ?? record.Snapshot.ObjScale ?? 1f; + if (!TryGetCollisionSphere(setup, scale, out ProjectileCollisionSphere sphere)) + { + DiagnosticSink?.Invoke( + $"Missile 0x{record.ServerGuid:X8} Setup 0x{record.Snapshot.SetupTableId ?? 0u:X8} " + + $"does not have the supported retail one-sphere collision shape."); + return false; + } + + if (!double.IsFinite(currentTime)) + { + DiagnosticSink?.Invoke( + $"Missile 0x{record.ServerGuid:X8} has an invalid physics clock."); + return false; + } + _lastFiniteGameTime = currentTime; + + PhysicsBody body; + if (record.RemoteMotionRuntime is { } remote) + { + // Retail owns one CPhysicsObj. If a non-missile incarnation + // already created its MovementManager, classification adopts that + // same body instead of replacing it. + body = remote.Body; + uint currentCellId = record.FullCellId; + Vector3 currentCellLocal = CellLocalFromWorld( + body.Position, + currentCellId, + liveCenterX, + liveCenterY); + if (!IsFinite(body.Position) + || !IsFinite(body.Velocity) + || !IsFinite(body.Omega) + || !PositionFrameValidation.IsValid( + currentCellId, + currentCellLocal, + body.Orientation)) + { + DiagnosticSink?.Invoke( + $"Missile 0x{record.ServerGuid:X8} has an invalid current shared physics frame or vector."); + return false; + } + body.State = record.FinalPhysicsState; + body.Friction = NormalizeFriction( + physics.Friction ?? record.Snapshot.Friction); + body.Elasticity = NormalizeElasticity( + physics.Elasticity ?? record.Snapshot.Elasticity ?? 0.05f); + + // The MovementManager can predate missile classification and may + // already have advanced beyond the retained CreateObject frame. + // Preserve its current velocity, omega, orientation, and Active + // state exactly as retail SetState does. Only normalize the + // carried cell frame and translate the legacy remote clock onto + // the App projectile clock. + body.SnapToCell( + currentCellId, + body.Position, + currentCellLocal); + body.LastUpdateTime = currentTime; + } + else + { + Vector3 velocity = physics.Velocity ?? Vector3.Zero; + Vector3 omega = physics.AngularVelocity ?? Vector3.Zero; + if (!IsFinite(entity.Position) + || !PositionFrameValidation.IsValid( + wirePosition.LandblockId, + new Vector3( + wirePosition.PositionX, + wirePosition.PositionY, + wirePosition.PositionZ), + entity.Rotation) + || !IsFinite(velocity) + || !IsFinite(omega)) + { + DiagnosticSink?.Invoke( + $"Missile 0x{record.ServerGuid:X8} has an invalid initial physics frame or vector."); + return false; + } + body = new PhysicsBody + { + State = record.FinalPhysicsState, + Friction = NormalizeFriction( + physics.Friction ?? record.Snapshot.Friction), + Elasticity = NormalizeElasticity( + physics.Elasticity ?? record.Snapshot.Elasticity ?? 0.05f), + Orientation = entity.Rotation, + LastUpdateTime = currentTime, + }; + SetVelocity(body, velocity, currentTime); + body.Omega = omega; + body.SnapToCell( + wirePosition.LandblockId, + entity.Position, + new Vector3( + wirePosition.PositionX, + wirePosition.PositionY, + wirePosition.PositionZ)); + } + + uint canonicalCellId = body.CellPosition.ObjCellId; + entity.SetPosition(body.Position); + entity.Rotation = body.Orientation; + entity.ParentCellId = canonicalCellId; + _liveEntities.RebucketLiveEntity(record.ServerGuid, canonicalCellId); + + var runtime = new Runtime( + record.Generation, + body, + sphere, + entity.MeshRefs.Count > 0); + _liveEntities.SetProjectileRuntime(record.ServerGuid, runtime); + + if (HasVisibleCell(record)) + { + ShadowPositionSynchronizer.Sync( + _shadows, + entity.Id, + entity.Position, + entity.Rotation, + canonicalCellId, + liveCenterX, + liveCenterY); + } + else + { + SuspendOutsideWorld(runtime, entity.Id); + } + _publishRootPose(entity); + return true; + } + + internal bool ApplyAuthoritativeVector( + uint serverGuid, + Vector3 velocity, + Vector3 angularVelocity, + double currentTime) + { + if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out Runtime runtime)) + return false; + + // Once Missile is cleared, an existing MovementManager owns ordinary + // jump/vector side effects (Airborne, gravity, LeaveGround) on this + // same body. Let that established funnel consume the packet. A + // projectile-only object still needs the unconditional retail + // set_velocity/set_omega writes here. + if ((record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0 + && record.RemoteMotionRuntime is not null) + return false; + + if (!IsFinite(velocity) + || !IsFinite(angularVelocity) + || !double.IsFinite(currentTime)) + { + DiagnosticSink?.Invoke( + $"Rejected invalid VectorUpdate for missile 0x{serverGuid:X8}."); + return true; + } + _lastFiniteGameTime = currentTime; + + SetVelocity(runtime.Body, velocity, currentTime); + runtime.Body.Omega = angularVelocity; + if (!_liveEntities.ShouldAdvanceRootRuntime(serverGuid)) + Deactivate(runtime.Body); + return true; + } + + internal bool ApplyAuthoritativeState( + uint serverGuid, + PhysicsStateFlags state, + double currentTime, + int liveCenterX, + int liveCenterY) + { + if (!_liveEntities.TryGetRecord(serverGuid, out LiveEntityRecord record)) + return false; + + bool validClock = double.IsFinite(currentTime); + double effectiveClock = validClock + ? currentTime + : _lastFiniteGameTime; + if (validClock) + _lastFiniteGameTime = currentTime; + if (!validClock) + DiagnosticSink?.Invoke( + $"Ignored invalid State update clock for live object 0x{serverGuid:X8}; state remains authoritative."); + + if (record.ProjectileRuntime is Runtime runtime) + { + bool wasMissile = + (runtime.Body.State & PhysicsStateFlags.Missile) != 0; + runtime.Body.State = state; + if ((state & PhysicsStateFlags.Missile) != 0 && !wasMissile) + { + // Ownership transfers back from generic remote motion to the + // absolute projectile clock. State alone must not activate, + // but an already-active body keeps moving from now rather + // than replaying the ordinary-motion interval. A malformed + // local receipt clock is not retail state and cannot clear + // Active; translate to the last finite App clock instead. + runtime.Body.LastUpdateTime = effectiveClock; + if (record.FullCellId != 0) + { + runtime.Body.SnapToCell( + record.FullCellId, + runtime.Body.Position, + CellLocalFromWorld( + runtime.Body.Position, + record.FullCellId, + liveCenterX, + liveCenterY)); + } + } + return true; + } + + // SetState applies to the canonical CPhysicsObj independently of + // whether this state also makes projectile-component acquisition + // possible. Missing DAT/shape data must never leave the existing body + // on stale collision flags. + if (record.RemoteMotionRuntime is { } remote) + remote.Body.State = state; + + if ((state & PhysicsStateFlags.Missile) == 0 + || record.WorldEntity is not { } entity) + return false; + + // A newer State packet can make an already materialized object a + // missile. TryBind adopts an existing MovementManager body so the + // live record still represents retail's one CPhysicsObj. + Setup? setup = _setupResolver(entity.SourceGfxObjOrSetupId); + if (setup is null) + { + DiagnosticSink?.Invoke( + $"Cannot activate missile 0x{serverGuid:X8}: Setup 0x{entity.SourceGfxObjOrSetupId:X8} is unavailable."); + return true; + } + + TryBind(record, setup, effectiveClock, liveCenterX, liveCenterY); + return true; + } + + /// + /// Applies a timestamp-gated server correction to the same predicted body. + /// The caller supplies both render-world and wire cell-local coordinates so + /// streaming origin changes never leak into the canonical cell frame. + /// + internal bool ApplyAuthoritativePosition( + uint serverGuid, + Vector3 worldPosition, + Vector3 cellLocalPosition, + Quaternion orientation, + uint fullCellId, + double currentTime, + int liveCenterX, + int liveCenterY) + { + if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out Runtime runtime) + || (record.FinalPhysicsState & PhysicsStateFlags.Missile) == 0 + || record.WorldEntity is not { } entity) + return false; + + if (!double.IsFinite(currentTime) + || !IsFinite(worldPosition) + || !IsFinite(cellLocalPosition) + || !PositionFrameValidation.IsValid( + fullCellId, + cellLocalPosition, + orientation)) + { + DiagnosticSink?.Invoke( + $"Rejected invalid PositionUpdate for missile 0x{serverGuid:X8}."); + return true; + } + _lastFiniteGameTime = currentTime; + + PhysicsBody body = runtime.Body; + bool wasInWorld = body.InWorld; + body.Orientation = orientation; + body.SnapToCell(fullCellId, worldPosition, cellLocalPosition); + body.State = record.FinalPhysicsState; + uint canonicalCellId = body.CellPosition.ObjCellId; + + entity.SetPosition(worldPosition); + entity.Rotation = orientation; + entity.ParentCellId = canonicalCellId; + _liveEntities.RebucketLiveEntity(serverGuid, canonicalCellId); + if (HasVisibleCell(record)) + { + if (!wasInWorld) + Activate(body, currentTime); + body.InWorld = true; + ShadowPositionSynchronizer.Sync( + _shadows, + entity.Id, + worldPosition, + orientation, + canonicalCellId, + liveCenterX, + liveCenterY); + } + else + { + SuspendOutsideWorld(runtime, entity.Id); + } + _publishRootPose(entity); + return true; + } + + /// + /// Movement packets use the ordinary MovementManager/animation funnel on + /// this same body. The generic remote translation tick consults this gate + /// so only the projectile integrator commits its root frame. + /// + internal bool HandlesMovement(uint serverGuid) => + TryGetCurrent(serverGuid, out LiveEntityRecord record, out _) + && (record.FinalPhysicsState & PhysicsStateFlags.Missile) != 0; + + internal bool TryGetBody(uint serverGuid, out PhysicsBody body) + { + if (TryGetCurrent(serverGuid, out _, out Runtime runtime)) + { + body = runtime.Body; + return true; + } + body = null!; + return false; + } + + internal bool LeaveWorld(uint serverGuid) + { + if (!TryGetCurrent(serverGuid, out LiveEntityRecord record, out Runtime runtime) + || record.WorldEntity is not { } entity) + return false; + SuspendOutsideWorld(runtime, entity.Id); + return true; + } + + internal void Tick( + double currentTime, + int liveCenterX, + int liveCenterY, + Vector3? playerWorldPosition) + { + if (!double.IsFinite(currentTime)) + { + DiagnosticSink?.Invoke("Rejected non-finite projectile update clock."); + return; + } + _lastFiniteGameTime = currentTime; + + foreach (LiveEntityRecord record in _liveEntities.Records.ToArray()) + { + if (record.ProjectileRuntime is not Runtime runtime + || runtime.Generation != record.Generation + || record.WorldEntity is not { } entity) + continue; + + bool isMissile = + (record.FinalPhysicsState & PhysicsStateFlags.Missile) != 0; + if (!isMissile) + { + // World entry/exit is independent of Missile classification. + // A projectile can enter a pending bucket, lose Missile, then + // hydrate as an ordinary live object. Restore its retained + // shadow/body membership without reactivating projectile + // integration or consuming the out-of-world clock gap. + if (!HasVisibleCell(record)) + { + SuspendOutsideWorld(runtime, entity.Id); + } + else if (!runtime.Body.InWorld) + { + runtime.Body.InWorld = true; + ShadowPositionSynchronizer.Sync( + _shadows, + entity.Id, + runtime.Body.Position, + runtime.Body.Orientation, + record.FullCellId, + liveCenterX, + liveCenterY); + _publishRootPose(entity); + } + // A retained body without MovementManager still remains an + // active CPhysicsObj after Missile is cleared. Continue the + // same one-sphere update path; PhysicsEngine reads the final + // state and therefore disables missile_ignore automatically. + if (record.RemoteMotionRuntime is not null) + continue; + } + + runtime.Body.State = record.FinalPhysicsState; + if (!_liveEntities.ShouldAdvanceRootRuntime(record.ServerGuid)) + { + if (!HasVisibleCell(record)) + SuspendOutsideWorld(runtime, entity.Id); + else + Deactivate(runtime.Body); // Frozen retains its cell/shadow. + continue; + } + + if (!runtime.Body.InWorld) + { + runtime.Body.InWorld = true; + Activate(runtime.Body, currentTime); + ShadowPositionSynchronizer.Sync( + _shadows, + entity.Id, + runtime.Body.Position, + runtime.Body.Orientation, + record.FullCellId, + liveCenterX, + liveCenterY); + _publishRootPose(entity); + } + + // CPhysicsObj::update_object 0x00515D4D-0x00515DB2 keeps + // rendered objects active only within 96 world units of the + // player. Objects with no PartArray are exempt. + if (runtime.HasPartArray && playerWorldPosition is { } playerPosition) + { + float playerDistance = Vector3.Distance(runtime.Body.Position, playerPosition); + if (!float.IsFinite(playerDistance) || playerDistance > 96f) + { + Deactivate(runtime.Body); + continue; + } + Activate(runtime.Body, currentTime); + } + + bool isParented = record.Snapshot.ParentGuid is not null + || record.Snapshot.Physics?.Parent is not null; + ProjectileAdvanceResult result = _stepper.Advance( + runtime.Body, + currentTime, + record.FullCellId, + runtime.CollisionSphere, + entity.Id, + designatedTargetId: 0u, + isParented); + + // An owner can be deleted/replaced by a callback reached from a + // future collision sink. Never commit a completed step into a new + // incarnation that reused the same server GUID. + if (!TryGetCurrent(record.ServerGuid, out LiveEntityRecord current, out Runtime currentRuntime) + || !ReferenceEquals(runtime, currentRuntime) + || !ReferenceEquals(record, current) + || !result.Simulated) + continue; + + uint priorCellId = record.FullCellId; + uint resolvedCellId = result.CellId != 0 + ? result.CellId + : priorCellId; + + entity.SetPosition(runtime.Body.Position); + entity.Rotation = runtime.Body.Orientation; + entity.ParentCellId = resolvedCellId; + if (resolvedCellId != priorCellId) + _liveEntities.RebucketLiveEntity(record.ServerGuid, resolvedCellId); + runtime.Body.SnapToCell( + resolvedCellId, + runtime.Body.Position, + CellLocalFromWorld( + runtime.Body.Position, + resolvedCellId, + liveCenterX, + liveCenterY)); + + // A successful transition can cross into an unloaded landblock. + // Rebucket first, then make the physics/shadow decision from the + // resulting projection state in this same update quantum. Retail + // never leaves a CPhysicsObj active in a cell that is outside the + // current object-maintenance world. + if (HasVisibleCell(record)) + { + ShadowPositionSynchronizer.Sync( + _shadows, + entity.Id, + runtime.Body.Position, + runtime.Body.Orientation, + record.FullCellId, + liveCenterX, + liveCenterY); + _publishRootPose(entity); + } + else + { + SuspendOutsideWorld(runtime, entity.Id); + } + } + } + + private static bool HasVisibleCell(LiveEntityRecord record) => + record.IsSpatiallyProjected + && record.IsSpatiallyVisible + && record.FullCellId != 0; + + private void SuspendOutsideWorld(Runtime runtime, uint localEntityId) + { + runtime.Body.InWorld = false; + Deactivate(runtime.Body); + _shadows.Suspend(localEntityId); + } + + private static void Activate(PhysicsBody body, double currentTime) + { + if ((body.State & PhysicsStateFlags.Static) != 0) + return; + if ((body.TransientState & TransientStateFlags.Active) == 0) + body.LastUpdateTime = currentTime; + body.TransientState |= TransientStateFlags.Active; + } + + private static void Deactivate(PhysicsBody body) => + body.TransientState &= ~TransientStateFlags.Active; + + private static void SetVelocity( + PhysicsBody body, + Vector3 velocity, + double currentTime) + { + bool wasActive = (body.TransientState & TransientStateFlags.Active) != 0; + body.set_velocity(velocity); + if ((body.State & PhysicsStateFlags.Static) != 0) + { + Deactivate(body); + return; + } + if (!wasActive && (body.State & PhysicsStateFlags.Static) == 0) + body.LastUpdateTime = currentTime; + } + + private static float NormalizeFriction(float? value) => + value is >= 0f and <= 1f && float.IsFinite(value.Value) + ? value.Value + : PhysicsBody.DefaultFriction; + + private static float NormalizeElasticity(float value) + { + if (float.IsNaN(value) || value <= 0f) + return 0f; + return MathF.Min(value, 0.1f); + } + + private static bool IsFinite(Vector3 value) => + float.IsFinite(value.X) + && float.IsFinite(value.Y) + && float.IsFinite(value.Z); + + private static Vector3 CellLocalFromWorld( + Vector3 worldPosition, + uint cellId, + int liveCenterX, + int liveCenterY) + { + int landblockX = (int)((cellId >> 24) & 0xFFu); + int landblockY = (int)((cellId >> 16) & 0xFFu); + return worldPosition - new Vector3( + (landblockX - liveCenterX) * 192f, + (landblockY - liveCenterY) * 192f, + 0f); + } + + private bool TryGetCurrent( + uint serverGuid, + out LiveEntityRecord record, + out Runtime runtime) + { + if (_liveEntities.TryGetRecord(serverGuid, out record!) + && record.ProjectileRuntime is Runtime found + && found.Generation == record.Generation) + { + runtime = found; + return true; + } + + record = null!; + runtime = null!; + return false; + } + + private static bool TryGetCollisionSphere( + Setup setup, + float scale, + out ProjectileCollisionSphere sphere) + { + // Installed DAT audit in 2026-07-13-retail-projectile-vfx-pseudocode.md + // pins every required arrow, bolt, and spell projectile to one ordinary + // Setup sphere (including Force Bolt's non-centered origin). Do not + // fabricate a mesh-derived radius for an unsupported Setup. + if (setup.Spheres.Count == 1) + { + var source = setup.Spheres[0]; + sphere = new ProjectileCollisionSphere(source.Origin, source.Radius, scale); + if (sphere.IsValid) + return true; + } + + sphere = default; + return false; + } +} diff --git a/src/AcDream.App/Physics/RemotePhysicsBodyInitializer.cs b/src/AcDream.App/Physics/RemotePhysicsBodyInitializer.cs new file mode 100644 index 00000000..5f8cbd07 --- /dev/null +++ b/src/AcDream.App/Physics/RemotePhysicsBodyInitializer.cs @@ -0,0 +1,39 @@ +using System.Numerics; +using AcDream.App.World; +using AcDream.Core.Physics; + +namespace AcDream.App.Physics; + +/// +/// Applies CreateObject physics vectors when the ordinary remote-motion body +/// is first constructed. This keeps initialization at object/body creation; +/// a later SetState classification change never replays retained vectors. +/// +/// +/// Retail CPhysicsObj::set_description 0x00514F40 installs the +/// unpacked velocity/omega during object initialization. Retail +/// SmartBox::DoSetState 0x004520D0 only calls +/// CPhysicsObj::set_state and therefore preserves the current vectors. +/// +internal static class RemotePhysicsBodyInitializer +{ + internal static void Initialize(PhysicsBody body, LiveEntityRecord record) + { + ArgumentNullException.ThrowIfNull(body); + ArgumentNullException.ThrowIfNull(record); + + body.State = record.FinalPhysicsState; + if (record.Snapshot.Physics is not { } physics) + return; + + if (physics.Velocity is { } velocity && IsFinite(velocity)) + body.set_velocity(velocity); + if (physics.AngularVelocity is { } omega && IsFinite(omega)) + body.Omega = omega; + } + + private static bool IsFinite(Vector3 value) => + float.IsFinite(value.X) + && float.IsFinite(value.Y) + && float.IsFinite(value.Z); +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e4d5f862..ff9bfc1d 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -159,6 +159,9 @@ public sealed class GameWindow : IDisposable // once the injected shared helpers exist as method groups (GetSetupCylinder, // ApplyServerControlledVelocityCycle). See RemotePhysicsUpdater. private readonly AcDream.App.Physics.RemotePhysicsUpdater _remotePhysicsUpdater; + // Step 7 projectile presentation. The controller owns no identity map; + // each runtime component is stored on the canonical LiveEntityRecord. + private AcDream.App.Physics.ProjectileController? _projectileController; // Step 4: portal-based interior cell visibility. private readonly CellVisibility _cellVisibility = new(); @@ -456,7 +459,9 @@ public sealed class GameWindow : IDisposable /// remote gets the same treatment as the local player. /// /// - internal sealed class RemoteMotion : AcDream.App.World.ILiveEntityRemoteMotionRuntime // internal: the R2-Q5 sink callbacks (ObservedOmega seam) capture it + internal sealed class RemoteMotion : + AcDream.App.World.ILiveEntityRemoteMotionRuntime, + AcDream.App.World.ILiveEntityCanonicalCellConsumer // internal: the R2-Q5 sink callbacks (ObservedOmega seam) capture it { public AcDream.Core.Physics.PhysicsBody Body; AcDream.Core.Physics.PhysicsBody AcDream.App.World.ILiveEntityRemoteMotionRuntime.Body => Body; @@ -586,7 +591,27 @@ public sealed class GameWindow : IDisposable /// UP lands, which disables the transition step for that frame /// (Euler alone, matching pre-wire behavior). /// - public uint CellId; + private uint _cellId; + private Func? _canonicalCellReader; + private Action? _canonicalCellWriter; + + /// + /// 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, + /// Movement packets, and spatial rebucketing cannot develop competing + /// cell identities. + /// + public uint CellId + { + get => _canonicalCellReader?.Invoke() ?? _cellId; + set + { + _cellId = value; + _canonicalCellWriter?.Invoke(value); + } + } /// /// K-fix9 (2026-04-26): true while the remote is airborne (jump @@ -657,9 +682,9 @@ public sealed class GameWindow : IDisposable /// public float MaxSeqSpeedSinceLastUP; - public RemoteMotion() + public RemoteMotion(AcDream.Core.Physics.PhysicsBody? sharedBody = null) { - Body = new AcDream.Core.Physics.PhysicsBody + Body = sharedBody ?? new AcDream.Core.Physics.PhysicsBody { // Remotes don't simulate gravity — server owns Z. Force // Contact + OnWalkable + Active so apply_current_movement @@ -692,6 +717,12 @@ public sealed class GameWindow : IDisposable WeenieObj = new AcDream.Core.Physics.RemoteWeenie(), }); } + + public void BindCanonicalCell(Func read, Action write) + { + _canonicalCellReader = read ?? throw new ArgumentNullException(nameof(read)); + _canonicalCellWriter = write ?? throw new ArgumentNullException(nameof(write)); + } } /// Soft-snap decay rate (1/sec). At this rate the residual @@ -2397,6 +2428,22 @@ public sealed class GameWindow : IDisposable if (record.WorldEntity is { } entity) _particleSink!.SetEntityPresentationVisible(entity.Id, visible); }; + _projectileController = new AcDream.App.Physics.ProjectileController( + _liveEntities, + _physicsEngine, + setupId => + { + // State can classify an already-live object as Missile + // outside the spawn handler's lock. DatCollection shares + // one mutable reader cursor with the streaming worker, so + // this resolver must serialize both initial and late bind. + lock (_datLock) + return _dats?.Get(setupId); + }, + entity => _effectPoses.UpdateRoot(entity)) + { + DiagnosticSink = message => Console.Error.WriteLine($"projectile: {message}"), + }; _liveEntityLights = new AcDream.App.Rendering.Vfx.LiveEntityLightController( _liveEntities, @@ -3143,8 +3190,8 @@ public sealed class GameWindow : IDisposable uint canonical = (loadedLandblockId & 0xFFFF0000u) | 0xFFFFu; LiveEntityRecord[] records = _liveEntities.Records .Where(record => record.ServerGuid != _playerServerGuid - && record.Snapshot.Position is { } position - && ((position.LandblockId & 0xFFFF0000u) | 0xFFFFu) == canonical + && record.ProjectionCellId != 0 + && ((record.ProjectionCellId & 0xFFFF0000u) | 0xFFFFu) == canonical && record.Snapshot.SetupTableId is not null) .ToArray(); if (records.Length == 0) return; @@ -3162,7 +3209,7 @@ public sealed class GameWindow : IDisposable { if (_liveEntities.RebucketLiveEntity( record.ServerGuid, - record.Snapshot.Position!.Value.LandblockId)) + record.ProjectionCellId)) projected++; } else @@ -3906,6 +3953,13 @@ public sealed class GameWindow : IDisposable // falls through to OK_TS for any object with no collision // geometry (acclient_2013_pseudo_c.txt:276917,276987). RegisterLiveEntityCollision(entity, setup, spawn, origin); + if (_liveEntities.TryGetRecord(spawn.Guid, out var liveRecord)) + _projectileController?.TryBind( + liveRecord, + setup, + _physicsScriptGameTime, + _liveCenterX, + _liveCenterY); // Phase B.2: capture the server-sent MotionTableId for our own // character so UpdatePlayerAnimation can pass it to GetIdleCycle. @@ -4923,6 +4977,9 @@ public sealed class GameWindow : IDisposable _translucencyFades.ClearEntity(existingEntity.Id); // #188 LeaveWorldLiveEntityRuntimeComponents(record); + // Logical teardown ends the retained shadow payload too. The weaker + // pickup/parent path stops after Suspend so re-entry can restore it. + _physicsEngine.ShadowObjects.Deregister(existingEntity.Id); _liveEntityLights?.Forget(existingEntity.Id); } @@ -4938,8 +4995,11 @@ public sealed class GameWindow : IDisposable if (record.WorldEntity is not { } entity) return; + bool retainedProjectileShadow = + _projectileController?.LeaveWorld(record.ServerGuid) == true; _worldGameState.RemoveById(entity.Id); - _physicsEngine.ShadowObjects.Deregister(entity.Id); + if (!retainedProjectileShadow) + _physicsEngine.ShadowObjects.Deregister(entity.Id); if (record.ServerGuid == _playerServerGuid) _lastLocalPlayerShadow = null; @@ -5429,7 +5489,7 @@ public sealed class GameWindow : IDisposable // rm on first UM exactly like the UP handler does. if (!_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot)) { - remoteMot = new RemoteMotion(); + remoteMot = CreateRemoteMotion(update.Guid); remoteMot.Body.Orientation = entity.Rotation; remoteMot.Body.Position = entity.Position; _remoteDeadReckon[update.Guid] = remoteMot; @@ -5591,16 +5651,48 @@ public sealed class GameWindow : IDisposable /// LoginComplete so the server knows the client has loaded the destination. /// /// - /// K-fix9 (2026-04-26): handle 0xF74E VectorUpdate — ACE broadcasts - /// this on remote-player JUMPS (Player.cs:954). The payload carries - /// the world-space launch velocity. Without handling it, remote - /// jumps render as a tiny lift-and-back because we never see the - /// +Z velocity that would integrate into a proper arc. + /// Creates the optional MovementManager companion for one live object. + /// A retained projectile contributes the already-owned CPhysicsObj body + /// and canonical live-record cell; an ordinary remote gets local storage. + /// + private RemoteMotion CreateRemoteMotion(uint serverGuid) + { + if (_projectileController?.TryGetBody(serverGuid, out var projectileBody) != true) + { + var remote = new RemoteMotion(); + if (_liveEntities?.TryGetRecord(serverGuid, out var record) == true) + AcDream.App.Physics.RemotePhysicsBodyInitializer.Initialize( + remote.Body, + record); + return remote; + } + + // Retail has one CPhysicsObj. When a MovementManager is added after a + // missile body already exists, share both its body and its canonical + // live-record cell rather than introducing a second cell owner. + return new RemoteMotion(projectileBody); + } + + /// + /// K-fix9 (2026-04-26): handle 0xF74E VectorUpdate from remote jumps. + /// The payload seeds the world-space launch velocity and angular velocity. /// private void OnLiveVectorUpdated(AcDream.Core.Net.Messages.VectorUpdate.Parsed update) { + if (_projectileController?.CanAcceptVectorPayload( + update.Guid, + update.Velocity, + update.Omega) == false) + return; if (!_liveEntities!.TryApplyVector(update, out _)) return; + if (_projectileController?.ApplyAuthoritativeVector( + update.Guid, + update.Velocity, + update.Omega, + _physicsScriptGameTime) == true) + return; + if (!_entitiesByServerGuid.ContainsKey(update.Guid)) return; if (update.Guid == _playerServerGuid) return; // local jump uses our own physics @@ -5608,7 +5700,7 @@ public sealed class GameWindow : IDisposable // World-space velocity. Apply directly to the body — the per-tick // remote update will integrate Position += Velocity × dt + 0.5 × Accel × dt². - rm.Body.Velocity = update.Velocity; + rm.Body.set_velocity(update.Velocity); // L.3.1 Task 6: apply Omega too. Was parsed but ignored, leaving // remote jumping/turning arcs flat. Mirrors retail SmartBox:: @@ -5647,7 +5739,7 @@ public sealed class GameWindow : IDisposable { EnsureRemoteMotionBindings(rm, ae, update.Guid); rm.Motion.LeaveGround(); - rm.Body.Velocity = update.Velocity; + rm.Body.set_velocity(update.Velocity); rm.Body.Omega = update.Omega; } } @@ -5671,6 +5763,12 @@ public sealed class GameWindow : IDisposable { if (!_liveEntities!.TryApplyState(parsed, out _)) return; + _projectileController?.ApplyAuthoritativeState( + parsed.Guid, + (AcDream.Core.Physics.PhysicsStateFlags)parsed.PhysicsState, + _physicsScriptGameTime, + _liveCenterX, + _liveCenterY); _liveEntityLights?.OnStateChanged(parsed.Guid); if (!_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity)) return; @@ -5803,6 +5901,11 @@ public sealed class GameWindow : IDisposable private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update) { + if (_projectileController?.CanAcceptPositionPayload( + update.Guid, + update.Position, + update.Velocity) == false) + return; bool known = _liveEntities!.TryApplyPosition( update, isLocalPlayer: update.Guid == _playerServerGuid, @@ -5892,6 +5995,24 @@ public sealed class GameWindow : IDisposable : new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW); DumpMovementTruthServerEcho(update, worldPos); + // Missiles reconcile the same predicted PhysicsBody in place. The + // timestamp gate above already rejected stale corrections; returning + // here prevents the generic remote locomotion path from allocating a + // second body or interpolation owner for the projectile. + if (_projectileController?.ApplyAuthoritativePosition( + update.Guid, + worldPos, + new System.Numerics.Vector3( + p.PositionX, + p.PositionY, + p.PositionZ), + rot, + p.LandblockId, + _physicsScriptGameTime, + _liveCenterX, + _liveCenterY) == true) + return; + // Capture the pre-update render position for the soft-snap residual // calculation below. Assign entity.Position to the server truth up // front; if we then compute a snap residual, we restore the rendered @@ -5956,7 +6077,7 @@ public sealed class GameWindow : IDisposable // dispatcher). if (!_remoteDeadReckon.TryGetValue(update.Guid, out var rmState)) { - rmState = new RemoteMotion(); + rmState = CreateRemoteMotion(update.Guid); _remoteDeadReckon[update.Guid] = rmState; // Hard-snap orientation on first spawn so the per-tick // slerp doesn't visibly rotate from Identity to truth. @@ -9153,6 +9274,13 @@ public sealed class GameWindow : IDisposable // Re-publish without advancing so an extra render between updates still // retains retail's animation-hook versus object-hook phase barrier. _scriptRunner?.PublishTime(_physicsScriptGameTime); + _projectileController?.Tick( + _physicsScriptGameTime, + _liveCenterX, + _liveCenterY, + _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var projectileViewer) + ? projectileViewer.Position + : null); if (_animatedEntities.Count > 0) TickAnimations((float)deltaSeconds); _equippedChildRenderer?.Tick(); @@ -10322,6 +10450,7 @@ public sealed class GameWindow : IDisposable && serverGuid != 0 && serverGuid != _playerServerGuid && _remoteDeadReckon.TryGetValue(serverGuid, out var rm) + && _projectileController?.HandlesMovement(serverGuid) != true // R4-V5 door fix companion: rm entries now also exist for // static animated objects (doors/levers — created on first // UM so the funnel can play their On/Off cycles). Those diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index 8b5e5b4e..e2a32d13 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -21,8 +21,22 @@ public interface ILiveEntityRemoteMotionRuntime PhysicsBody Body { get; } } -/// Marker for the projectile runtime added by the projectile phase. -public interface ILiveEntityProjectileRuntime { } +/// +/// 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); +} + +/// Projectile physics state owned by a live object. +public interface ILiveEntityProjectileRuntime +{ + PhysicsBody Body { get; } +} /// Marker for the DAT-driven effect profile added by the effect phase. public interface ILiveEntityEffectProfile { } @@ -96,6 +110,15 @@ public sealed class LiveEntityRecord public bool WorldSpawnPublished { get; internal set; } public LiveEntityProjectionKind ProjectionKind { get; internal set; } + /// + /// Cell used when a streamed landblock asks live objects to restore their + /// spatial projection. Once materialized, the canonical runtime cell wins; + /// the retained server snapshot is only a first-materialization source. + /// + public uint ProjectionCellId => WorldEntity is not null + ? FullCellId + : Snapshot.Position?.LandblockId ?? FullCellId; + internal void RefreshDerivedState(bool refreshPosition = true) { if (refreshPosition && Snapshot.Position is { } position) @@ -480,8 +503,38 @@ public sealed class LiveEntityRuntime ArgumentNullException.ThrowIfNull(runtime); if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)) throw new InvalidOperationException($"Cannot bind remote motion before live entity 0x{serverGuid:X8} exists."); + if (record.ProjectileRuntime is { } projectile + && !ReferenceEquals(projectile.Body, runtime.Body)) + { + throw new InvalidOperationException( + $"Live entity 0x{serverGuid:X8} already owns a different projectile physics body."); + } record.RemoteMotionRuntime = runtime; record.PhysicsBody = runtime.Body; + if (runtime is ILiveEntityCanonicalCellConsumer cellConsumer) + { + // Capture the incarnation, not only its GUID. A callback retained + // by an old MovementManager must never mutate a replacement that + // later reuses the same server GUID. + LiveEntityRecord incarnation = record; + cellConsumer.BindCanonicalCell( + read: () => + _recordsByGuid.TryGetValue(serverGuid, out var current) + && ReferenceEquals(current, incarnation) + && ReferenceEquals(current.RemoteMotionRuntime, runtime) + ? current.FullCellId + : 0u, + write: cellId => + { + if (cellId != 0 + && _recordsByGuid.TryGetValue(serverGuid, out var current) + && ReferenceEquals(current, incarnation) + && ReferenceEquals(current.RemoteMotionRuntime, runtime)) + { + RebucketLiveEntity(serverGuid, cellId); + } + }); + } _remoteMotionByGuid[serverGuid] = runtime; } @@ -495,7 +548,61 @@ public sealed class LiveEntityRuntime return false; _remoteMotionByGuid.Remove(serverGuid); record.RemoteMotionRuntime = null; - record.PhysicsBody = null; + if (record.ProjectileRuntime is null) + record.PhysicsBody = null; + return true; + } + + public void SetProjectileRuntime(uint serverGuid, ILiveEntityProjectileRuntime runtime) + { + ArgumentNullException.ThrowIfNull(runtime); + if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + || record.WorldEntity is null) + { + throw new InvalidOperationException( + $"Cannot bind projectile physics before live entity 0x{serverGuid:X8} is materialized."); + } + if (record.RemoteMotionRuntime is { } remote + && !ReferenceEquals(remote.Body, runtime.Body)) + { + throw new InvalidOperationException( + $"Live entity 0x{serverGuid:X8} already owns a different remote-motion physics body."); + } + if (record.ProjectileRuntime is { } projectile + && !ReferenceEquals(projectile.Body, runtime.Body)) + { + throw new InvalidOperationException( + $"Live entity 0x{serverGuid:X8} already owns a different projectile physics body."); + } + + record.ProjectileRuntime = runtime; + record.PhysicsBody = runtime.Body; + } + + public bool TryGetProjectileRuntime( + uint serverGuid, + out ILiveEntityProjectileRuntime runtime) + { + if (_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + && record.ProjectileRuntime is { } found) + { + runtime = found; + return true; + } + + runtime = null!; + return false; + } + + public bool ClearProjectileRuntime(uint serverGuid) + { + if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record) + || record.ProjectileRuntime is null) + return false; + + record.ProjectileRuntime = null; + if (record.RemoteMotionRuntime is null) + record.PhysicsBody = null; return true; } diff --git a/src/AcDream.Core/Physics/ProjectilePhysicsStepper.cs b/src/AcDream.Core/Physics/ProjectilePhysicsStepper.cs index 54c84ddb..a48b645b 100644 --- a/src/AcDream.Core/Physics/ProjectilePhysicsStepper.cs +++ b/src/AcDream.Core/Physics/ProjectilePhysicsStepper.cs @@ -198,6 +198,9 @@ public sealed class ProjectilePhysicsStepper body.Position = beginPosition; body.Orientation = beginOrientation; + ObjectInfoState moverFlags = body.State.HasFlag(PhysicsStateFlags.PathClipped) + ? ObjectInfoState.PathClipped + : ObjectInfoState.None; var resolved = _physics.ResolveWithTransition( beginPosition, candidatePosition, @@ -208,7 +211,7 @@ public sealed class ProjectilePhysicsStepper stepDownHeight: 0f, isOnGround: previousOnWalkable, body: body, - moverFlags: ObjectInfoState.PathClipped, + moverFlags: moverFlags, movingEntityId: movingEntityId, localSphereOrigin: sphere.LocalOrigin, beginOrientation: beginOrientation, diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index ee31f9a0..6e972cec 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -28,6 +28,7 @@ public sealed class ShadowObjectRegistry { private readonly Dictionary> _cells = new(); private readonly Dictionary> _entityToCells = new(); // for deregistration + private readonly HashSet _suspendedEntities = new(); /// /// A6.P4 door fix (2026-05-24): per-entity original shape list, used by @@ -326,6 +327,33 @@ public sealed class ShadowObjectRegistry reg.State, reg.Flags, seedCellId, reg.IsStatic); } + /// + /// Removes an entity from every cell collision list while retaining the + /// exact registration and shape payload needed to restore it later. + /// This is the registry counterpart of retail + /// CPhysicsObj::remove_shadows_from_cells during temporary + /// leave-world/pending-cell residence; it is deliberately not logical + /// teardown. + /// + public bool Suspend(uint entityId) + { + if (!_entityReg.ContainsKey(entityId)) + return false; + + if (_entityToCells.TryGetValue(entityId, out var cellIds)) + { + foreach (uint cellId in cellIds) + { + if (_cells.TryGetValue(cellId, out var list)) + list.RemoveAll(entry => entry.EntityId == entityId); + } + _entityToCells.Remove(entityId); + } + + _suspendedEntities.Add(entityId); + return true; + } + /// /// BR-7 streaming hook — re-run the flood for every entity whose seed /// cell or current cell set touches 's @@ -343,6 +371,9 @@ public sealed class ShadowObjectRegistry foreach (var kvp in _entityReg) { + if (_suspendedEntities.Contains(kvp.Key)) + continue; + if ((kvp.Value.SeedCellId & 0xFFFF0000u) == lbPrefix) { toReflood.Add(kvp.Key); @@ -407,6 +438,11 @@ public sealed class ShadowObjectRegistry /// public void UpdatePhysicsState(uint entityId, uint newState) { + // Suspended dynamic objects have no cell rows, but their retained + // registration must still receive authoritative state changes. + if (_entityReg.TryGetValue(entityId, out var retainedRegistration)) + _entityReg[entityId] = retainedRegistration with { State = newState }; + if (!_entityToCells.TryGetValue(entityId, out var cellIds)) return; // not registered — no-op @@ -438,15 +474,17 @@ public sealed class ShadowObjectRegistry } _entityShapes.Remove(entityId); _entityReg.Remove(entityId); + _suspendedEntities.Remove(entityId); } /// /// Remove all entities belonging to a landblock. With flood-driven - /// registration an entity's cells can span landblock prefixes; entries - /// under OTHER prefixes survive, and the entity is fully dropped only - /// when no cells remain (its owner despawns it via - /// in the normal path — retail's per-object - /// remove_shadows_from_cells, Ghidra 0x00511230). + /// registration an entity's cells can span landblock prefixes, so entries + /// under other prefixes survive. A static registration ends when its final + /// streamed cell disappears. A dynamic/server-live registration retains + /// its exact payload with zero cell rows so a later load can reflood it; + /// only its logical owner calls (retail's + /// per-object remove_shadows_from_cells, Ghidra 0x00511230). /// public void RemoveLandblock(uint landblockId) { @@ -473,8 +511,16 @@ public sealed class ShadowObjectRegistry foreach (var eid in entitiesToRemove) { _entityToCells.Remove(eid); - _entityShapes.Remove(eid); - _entityReg.Remove(eid); + // A streamed-out server-live object is still logically alive. + // Preserve dynamic registration/shape payload for the reload + // reflood; static owners end with their landblock. + if (!_entityReg.TryGetValue(eid, out var registration) + || registration.IsStatic) + { + _entityShapes.Remove(eid); + _entityReg.Remove(eid); + _suspendedEntities.Remove(eid); + } } } diff --git a/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs new file mode 100644 index 00000000..c90c4b4b --- /dev/null +++ b/tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs @@ -0,0 +1,1228 @@ +using System.Numerics; +using AcDream.App.Physics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.App.Tests.Physics; + +public sealed class ProjectileControllerTests +{ + private const uint Guid = 0x7000A001u; + private const uint CellA = 0x01010001u; + private const uint CellB = 0x01020001u; + private static readonly PhysicsStateFlags MissileState = + PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.Missile + | PhysicsStateFlags.AlignPath + | PhysicsStateFlags.PathClipped; + + private sealed class RecordingResources : ILiveEntityResourceLifecycle + { + public int Registers { get; private set; } + public int Unregisters { get; private set; } + public void Register(WorldEntity entity) => Registers++; + public void Unregister(WorldEntity entity) => Unregisters++; + } + + [Fact] + public void Tick_MovesCanonicalProjectionWithoutRecreatingResources() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1); + WorldEntity entity = record.WorldEntity!; + uint localId = entity.Id; + + Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 10.0)); + fixture.Controller.Tick(10.1, liveCenterX: 1, liveCenterY: 1, playerWorldPosition: null); + + Assert.Same(entity, record.WorldEntity); + Assert.Equal(localId, entity.Id); + Assert.True(entity.Position.X > 10f); + Assert.Equal(1, fixture.Resources.Registers); + Assert.Equal(0, fixture.Resources.Unregisters); + Assert.Equal(1, fixture.Controller.Count); + Assert.Same(record.ProjectileRuntime!.Body, record.PhysicsBody); + } + + [Fact] + public void PendingDestinationAndLaterHydration_RetainIdentityAndProjectileOwner() + { + var fixture = new Fixture(loadInitialLandblock: true); + LiveEntityRecord record = fixture.Spawn(instance: 1); + WorldEntity entity = record.WorldEntity!; + Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 2.0)); + ILiveEntityProjectileRuntime runtime = record.ProjectileRuntime!; + + Assert.True(fixture.Controller.ApplyAuthoritativePosition( + Guid, + worldPosition: new Vector3(202f, 10f, 5f), + cellLocalPosition: new Vector3(10f, 10f, 5f), + orientation: Quaternion.Identity, + fullCellId: CellB, + currentTime: 2.1, + liveCenterX: 1, + liveCenterY: 1)); + + Assert.False(record.IsSpatiallyVisible); + Assert.True(record.IsSpatiallyProjected); + Assert.Same(entity, record.WorldEntity); + Assert.Same(runtime, record.ProjectileRuntime); + + Vector3 pendingPosition = entity.Position; + fixture.Controller.Tick( + 2.4, + liveCenterX: 1, + liveCenterY: 1, + playerWorldPosition: null); + Assert.Equal(pendingPosition, entity.Position); + Assert.False(runtime.Body.InWorld); + Assert.False(runtime.Body.IsActive); + + fixture.Spatial.AddLandblock(EmptyLandblock(0x0102FFFFu)); + fixture.Controller.Tick( + 2.5, + liveCenterX: 1, + liveCenterY: 1, + playerWorldPosition: null); + + Assert.True(record.IsSpatiallyVisible); + Assert.Same(entity, record.WorldEntity); + Assert.Same(runtime, record.ProjectileRuntime); + Assert.Equal(1, fixture.Resources.Registers); + Assert.Equal(pendingPosition, entity.Position); + + fixture.Controller.Tick( + 2.6, + liveCenterX: 1, + liveCenterY: 1, + playerWorldPosition: null); + Assert.True(entity.Position.X > pendingPosition.X); + } + + [Fact] + public void LoadedLandblockCrossing_RebucketsSameProjection() + { + const uint startCell = 0xA9B40039u; + const uint destinationCell = 0xAAB40001u; + var fixture = new Fixture(loadInitialLandblock: false, BuildBoundaryEngine()); + fixture.Spatial.AddLandblock(EmptyLandblock(0xA9B4FFFFu)); + fixture.Spatial.AddLandblock(EmptyLandblock(0xAAB4FFFFu)); + LiveEntityRecord record = fixture.Spawn( + instance: 1, + cellId: startCell, + worldPosition: new Vector3(191f, 10f, 50f), + cellLocalPosition: new Vector3(191f, 10f, 50f), + velocity: new Vector3(40f, 0f, 0f)); + WorldEntity entity = record.WorldEntity!; + Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(radius: 0.5f), 0.0)); + + fixture.Controller.Tick( + 0.05, + liveCenterX: 0xA9, + liveCenterY: 0xB4, + playerWorldPosition: null); + + Assert.Equal(destinationCell, record.FullCellId); + Assert.Equal(destinationCell, entity.ParentCellId); + Assert.Equal(193f, entity.Position.X, 3); + Assert.Same(entity, record.WorldEntity); + Assert.Equal(1, fixture.Resources.Registers); + } + + [Fact] + public void PredictedCrossingIntoPendingLandblock_SuspendsInSameTick() + { + const uint startCell = 0xA9B40039u; + var fixture = new Fixture(loadInitialLandblock: false, BuildBoundaryEngine()); + fixture.Spatial.AddLandblock(EmptyLandblock(0xA9B4FFFFu)); + LiveEntityRecord record = fixture.Spawn( + instance: 1, + cellId: startCell, + worldPosition: new Vector3(191f, 10f, 50f), + cellLocalPosition: new Vector3(191f, 10f, 50f), + velocity: new Vector3(40f, 0f, 0f)); + WorldEntity entity = record.WorldEntity!; + fixture.Engine.ShadowObjects.Register( + entity.Id, + entity.SourceGfxObjOrSetupId, + entity.Position, + entity.Rotation, + radius: 0.5f, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: 0xA9B4FFFFu, + seedCellId: startCell, + isStatic: false); + Assert.True(fixture.Controller.TryBind( + record, + ProjectileSetup(radius: 0.5f), + 0.0, + liveCenterX: 0xA9, + liveCenterY: 0xB4)); + + fixture.Controller.Tick( + 0.05, + liveCenterX: 0xA9, + liveCenterY: 0xB4, + playerWorldPosition: null); + + Assert.Equal(0xAAB40001u, record.FullCellId); + Assert.False(record.IsSpatiallyVisible); + Assert.True(record.IsSpatiallyProjected); + Assert.False(record.PhysicsBody!.InWorld); + Assert.False(record.PhysicsBody.IsActive); + Assert.Empty(fixture.Engine.ShadowObjects.GetObjectsInCell(startCell)); + Assert.Empty(fixture.Engine.ShadowObjects.GetObjectsInCell(0xAAB40001u)); + Assert.Same(entity, record.WorldEntity); + Assert.Equal(1, fixture.Controller.Count); + + record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; + Assert.True(fixture.Controller.ApplyAuthoritativeState( + Guid, record.FinalPhysicsState, 0.06, 0xA9, 0xB4)); + fixture.Spatial.AddLandblock(EmptyLandblock(0xAAB4FFFFu)); + fixture.Controller.Tick(0.07, 0xA9, 0xB4, playerWorldPosition: null); + + Assert.True(record.IsSpatiallyVisible); + Assert.True(record.PhysicsBody.InWorld); + Assert.False(record.PhysicsBody.IsActive); + Assert.Contains( + fixture.Engine.ShadowObjects.GetObjectsInCell(0xAAB40001u), + entry => entry.EntityId == entity.Id); + } + + [Fact] + public void FreshVectorAndPositionCorrectionsMutateSameBody() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 3); + Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 4.0)); + PhysicsBody body = record.PhysicsBody!; + + Assert.True(fixture.Controller.ApplyAuthoritativeVector( + Guid, + new Vector3(7f, 8f, 9f), + new Vector3(0f, 0f, 2f), + currentTime: 4.5)); + Assert.Equal(new Vector3(7f, 8f, 9f), body.Velocity); + Assert.Equal(new Vector3(0f, 0f, 2f), body.Omega); + + var correction = new Vector3(30f, 31f, 32f); + Assert.True(fixture.Controller.ApplyAuthoritativePosition( + Guid, + correction, + new Vector3(30f, 31f, 32f), + Quaternion.Identity, + CellA, + currentTime: 5.0, + liveCenterX: 1, + liveCenterY: 1)); + + Assert.Same(body, record.PhysicsBody); + Assert.Equal(correction, body.Position); + Assert.Equal(correction, record.WorldEntity!.Position); + Assert.Equal(new Vector3(7f, 8f, 9f), body.Velocity); + } + + [Fact] + public void StaleVectorGateCannotOverwritePredictedBody() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 5, vectorSequence: 10); + Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0)); + PhysicsBody body = record.PhysicsBody!; + Vector3 original = body.Velocity; + + var stale = new VectorUpdate.Parsed( + Guid, + new Vector3(99f, 0f, 0f), + Vector3.Zero, + InstanceSequence: 5, + VectorSequence: 9); + + Assert.False(fixture.Live.TryApplyVector(stale, out _)); + Assert.Equal(original, body.Velocity); + } + + [Fact] + public void MissileStateRemovalAndReaddition_ReclassifiesWithoutReplacingEntity() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1); + Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0)); + Vector3 before = record.WorldEntity!.Position; + + ILiveEntityProjectileRuntime runtime = record.ProjectileRuntime!; + PhysicsBody body = runtime.Body; + record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; + Assert.True(fixture.Controller.ApplyAuthoritativeState( + Guid, + PhysicsStateFlags.ReportCollisions, + currentTime: 1.1, + liveCenterX: 1, + liveCenterY: 1)); + fixture.Controller.Tick( + 1.2, + liveCenterX: 1, + liveCenterY: 1, + playerWorldPosition: null); + + Assert.Same(runtime, record.ProjectileRuntime); + Assert.Same(body, record.PhysicsBody); + Assert.True(body.IsActive); + Assert.Equal(1, fixture.Controller.Count); + Assert.True(record.WorldEntity.Position.X > before.X); + + record.FinalPhysicsState = MissileState; + Assert.True(fixture.Controller.ApplyAuthoritativeState( + Guid, + MissileState, + currentTime: 1.3, + liveCenterX: 1, + liveCenterY: 1)); + Assert.Same(runtime, record.ProjectileRuntime); + Assert.Same(body, record.PhysicsBody); + Assert.True(body.IsActive); // State preserves an already-active body. + Assert.Same(record.WorldEntity, fixture.Live.MaterializedWorldEntities[Guid]); + Assert.Equal(1, fixture.Resources.Registers); + } + + [Fact] + public void StateReclassificationAfterPredictedCrossing_KeepsCurrentCellAndBody() + { + const uint startCell = 0xA9B40039u; + const uint destinationCell = 0xAAB40001u; + var fixture = new Fixture(loadInitialLandblock: false, BuildBoundaryEngine()); + fixture.Spatial.AddLandblock(EmptyLandblock(0xA9B4FFFFu)); + fixture.Spatial.AddLandblock(EmptyLandblock(0xAAB4FFFFu)); + LiveEntityRecord record = fixture.Spawn( + instance: 1, + cellId: startCell, + worldPosition: new Vector3(191f, 10f, 50f), + cellLocalPosition: new Vector3(191f, 10f, 50f), + velocity: new Vector3(40f, 0f, 0f)); + Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(0.5f), 0.0)); + ILiveEntityProjectileRuntime runtime = record.ProjectileRuntime!; + + fixture.Controller.Tick(0.05, 0xA9, 0xB4, playerWorldPosition: null); + Assert.Equal(destinationCell, record.FullCellId); + Assert.NotEqual(record.Snapshot.Position!.Value.LandblockId, record.FullCellId); + + Assert.True(fixture.Live.TryApplyState( + new SetState.Parsed( + Guid, + (uint)PhysicsStateFlags.ReportCollisions, + InstanceSequence: 1, + StateSequence: 2), + out _)); + Assert.True(fixture.Controller.ApplyAuthoritativeState( + Guid, record.FinalPhysicsState, 0.06, 0xA9, 0xB4)); + Assert.Equal(destinationCell, record.FullCellId); + Assert.True(fixture.Live.TryApplyState( + new SetState.Parsed( + Guid, + (uint)MissileState, + InstanceSequence: 1, + StateSequence: 3), + out _)); + Assert.True(fixture.Controller.ApplyAuthoritativeState( + Guid, MissileState, 0.07, 0xA9, 0xB4)); + + Assert.Same(runtime, record.ProjectileRuntime); + Assert.Same(runtime.Body, record.PhysicsBody); + Assert.Equal(destinationCell, record.FullCellId); + Assert.Equal(destinationCell, record.WorldEntity!.ParentCellId); + } + + [Fact] + public void FreshVector_ClampsImmediatelyAndRestartsInactiveClock() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1); + Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0)); + PhysicsBody body = record.PhysicsBody!; + body.TransientState &= ~TransientStateFlags.Active; + body.LastUpdateTime = 1.0; + + Assert.True(fixture.Controller.ApplyAuthoritativeVector( + Guid, + new Vector3(100f, 0f, 0f), + Vector3.Zero, + currentTime: 20.0)); + + Assert.Equal(50f, body.Velocity.Length(), 4); + Assert.Equal(20.0, body.LastUpdateTime, 8); + fixture.Controller.Tick( + 20.1, + liveCenterX: 1, + liveCenterY: 1, + playerWorldPosition: null); + Assert.Equal(15f, record.WorldEntity!.Position.X, 3); + } + + [Fact] + public void StateOnlyUpdate_DoesNotRestartStoppedProjectile() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1); + Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0)); + PhysicsBody body = record.PhysicsBody!; + body.TransientState &= ~TransientStateFlags.Active; + + Assert.True(fixture.Controller.ApplyAuthoritativeState( + Guid, + MissileState | PhysicsStateFlags.Inelastic, + currentTime: 2.0, + liveCenterX: 1, + liveCenterY: 1)); + + Assert.False(body.IsActive); + + record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; + Assert.True(fixture.Controller.ApplyAuthoritativeState( + Guid, record.FinalPhysicsState, 2.1, 1, 1)); + Assert.False(body.IsActive); + record.FinalPhysicsState = MissileState; + Assert.True(fixture.Controller.ApplyAuthoritativeState( + Guid, MissileState, 2.2, 1, 1)); + Assert.False(body.IsActive); + } + + [Fact] + public void VectorAfterMissileRemoval_StillMutatesRetainedRetailBody() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1); + Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0)); + PhysicsBody body = record.PhysicsBody!; + record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; + Assert.True(fixture.Controller.ApplyAuthoritativeState( + Guid, record.FinalPhysicsState, 1.01, 1, 1)); + + Assert.True(fixture.Controller.ApplyAuthoritativeVector( + Guid, + new Vector3(100f, 0f, 0f), + new Vector3(0f, 0f, 3f), + 1.02)); + + Assert.Same(body, record.PhysicsBody); + Assert.Equal(50f, body.Velocity.Length(), 4); + Assert.Equal(new Vector3(0f, 0f, 3f), body.Omega); + + Vector3 before = body.Position; + fixture.Controller.Tick(1.1, 1, 1, playerWorldPosition: null); + Assert.True(body.Position.X > before.X); + Assert.Equal(body.Position, record.WorldEntity!.Position); + } + + [Fact] + public void VectorAfterMissileRemoval_WithRemoteMotionDefersToOrdinaryFunnel() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1); + Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0)); + PhysicsBody body = record.PhysicsBody!; + fixture.Live.SetRemoteMotionRuntime(Guid, new GameWindow.RemoteMotion(body)); + record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; + Assert.True(fixture.Controller.ApplyAuthoritativeState( + Guid, record.FinalPhysicsState, 2.0, 1, 1)); + Vector3 before = body.Velocity; + + Assert.False(fixture.Controller.ApplyAuthoritativeVector( + Guid, + new Vector3(0f, 0f, 12f), + new Vector3(0f, 0f, 2f), + 3.0)); + + Assert.Equal(before, body.Velocity); + Assert.Same(body, record.RemoteMotionRuntime!.Body); + } + + [Fact] + public void MissileReacquisition_AlignsClockWithoutStateOnlyMotion() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1); + Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0)); + PhysicsBody body = record.PhysicsBody!; + Vector3 position = body.Position; + fixture.Live.SetRemoteMotionRuntime(Guid, new GameWindow.RemoteMotion(body)); + + record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; + Assert.True(fixture.Controller.ApplyAuthoritativeState( + Guid, record.FinalPhysicsState, 2.0, 1, 1)); + body.TransientState |= TransientStateFlags.Active; + body.LastUpdateTime = 9.5; // generic remote interval used another clock owner + body.set_velocity(new Vector3(40f, 0f, 0f)); + + record.FinalPhysicsState = MissileState; + Assert.True(fixture.Controller.ApplyAuthoritativeState( + Guid, MissileState, 10.0, 1, 1)); + Assert.Equal(10.0, body.LastUpdateTime, 8); + Assert.True(body.IsActive); + + fixture.Controller.Tick(10.1, 1, 1, playerWorldPosition: null); + Assert.Equal(position.X + 4f, body.Position.X, 3); + Assert.Equal(body.Position, record.WorldEntity!.Position); + } + + [Fact] + public void MissileReacquisition_AppliesStateWithoutPoisoningClock() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1); + Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0)); + PhysicsBody body = record.PhysicsBody!; + record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; + Assert.True(fixture.Controller.ApplyAuthoritativeState( + Guid, record.FinalPhysicsState, 2.0, 1, 1)); + PhysicsStateFlags stateBefore = body.State; + double clockBefore = body.LastUpdateTime; + + record.FinalPhysicsState = MissileState; + Assert.True(fixture.Controller.ApplyAuthoritativeState( + Guid, MissileState, double.NaN, 1, 1)); + + Assert.Equal(record.FinalPhysicsState, body.State); + Assert.NotEqual(stateBefore, body.State); + Assert.NotEqual(clockBefore, body.LastUpdateTime); + Assert.Equal(2.0, body.LastUpdateTime, 8); + Assert.True(double.IsFinite(body.LastUpdateTime)); + Assert.True(body.IsActive); + } + + [Fact] + public void Tick_PublishesMovedRootToEffectPoseSource() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1); + WorldEntity entity = record.WorldEntity!; + Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0)); + Assert.True(fixture.Poses.TryGetRootPose(entity.Id, out Matrix4x4 before)); + + fixture.Controller.Tick( + 1.1, + liveCenterX: 1, + liveCenterY: 1, + playerWorldPosition: null); + + Assert.True(fixture.Poses.TryGetRootPose(entity.Id, out Matrix4x4 after)); + Assert.NotEqual(before.Translation, after.Translation); + Assert.Equal(entity.Position, after.Translation); + } + + [Fact] + public void Tick_PartArrayUsesRetailNinetySixUnitActivationBoundary() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1, hasRenderablePart: true); + Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0)); + + fixture.Controller.Tick( + 1.1, + liveCenterX: 1, + liveCenterY: 1, + playerWorldPosition: new Vector3(106f, 10f, 5f)); + Assert.Equal(11f, record.WorldEntity!.Position.X, 3); + + fixture.Controller.Tick( + 1.2, + liveCenterX: 1, + liveCenterY: 1, + playerWorldPosition: new Vector3(107.1f, 10f, 5f)); + Assert.Equal(11f, record.WorldEntity.Position.X, 3); + Assert.False(record.PhysicsBody!.IsActive); + } + + [Fact] + public void Bind_UsesCanonicalOutdoorCellAndRehydrationCellTracksPrediction() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn( + instance: 1, + cellId: CellA, + worldPosition: new Vector3(30f, 10f, 5f), + cellLocalPosition: new Vector3(30f, 10f, 5f)); + + Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0)); + + Assert.Equal(record.PhysicsBody!.CellPosition.ObjCellId, record.FullCellId); + Assert.Equal(record.FullCellId, record.WorldEntity!.ParentCellId); + Assert.Equal(record.FullCellId, record.ProjectionCellId); + Assert.NotEqual(record.Snapshot.Position!.Value.LandblockId, record.FullCellId); + } + + [Fact] + public void MalformedFreshUpdates_DoNotPoisonCanonicalBodyOrPose() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1); + Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0)); + PhysicsBody body = record.PhysicsBody!; + Vector3 velocity = body.Velocity; + Vector3 position = body.Position; + + Assert.True(fixture.Controller.ApplyAuthoritativeVector( + Guid, + new Vector3(float.NaN, 0f, 0f), + Vector3.Zero, + currentTime: 2.0)); + Assert.Equal(velocity, body.Velocity); + + Assert.True(fixture.Controller.ApplyAuthoritativePosition( + Guid, + new Vector3(float.PositiveInfinity, 0f, 0f), + new Vector3(float.PositiveInfinity, 0f, 0f), + Quaternion.Identity, + CellA, + currentTime: 2.1, + liveCenterX: 1, + liveCenterY: 1)); + Assert.Equal(position, body.Position); + Assert.Equal(position, record.WorldEntity!.Position); + } + + [Fact] + public void MalformedPayloadGate_RejectsBeforeAuthoritativeSnapshotAdvances() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1, vectorSequence: 4); + record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; + WorldSession.EntitySpawn before = record.Snapshot; + + Assert.False(fixture.Controller.CanAcceptVectorPayload( + Guid, + new Vector3(float.NaN, 0f, 0f), + Vector3.Zero)); + Assert.False(fixture.Controller.CanAcceptPositionPayload( + Guid, + new CreateObject.ServerPosition( + CellA, + float.PositiveInfinity, + 0f, + 0f, + 1f, + 0f, + 0f, + 0f), + velocity: null)); + + var malformedPosition = new WorldSession.EntityPositionUpdate( + Guid, + new CreateObject.ServerPosition( + CellA, + 20f, + 20f, + 5f, + 1f, + 0f, + 0f, + 0f), + Velocity: new Vector3(float.NaN, 0f, 0f), + PlacementId: null, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: 2, + TeleportSequence: 0, + ForcePositionSequence: 0); + Assert.False(fixture.Controller.CanAcceptPositionPayload( + Guid, + malformedPosition.Position, + malformedPosition.Velocity)); + + Assert.Equal(before, record.Snapshot); + Assert.Equal((ushort)1, record.Snapshot.Physics!.Value.Timestamps.Position); + Assert.Equal((ushort)4, record.Snapshot.Physics!.Value.Timestamps.Vector); + + record.FinalPhysicsState = MissileState; + Assert.True(fixture.Controller.ApplyAuthoritativeState( + Guid, MissileState, 2.0, 1, 1)); + Assert.NotNull(record.ProjectileRuntime); + Assert.True(float.IsFinite(record.PhysicsBody!.Velocity.X)); + Assert.True(float.IsFinite(record.PhysicsBody.Velocity.Y)); + Assert.True(float.IsFinite(record.PhysicsBody.Velocity.Z)); + } + + [Fact] + public void StateUpdate_AlwaysMutatesExistingCanonicalBodyBeforeProjectileAcquisition() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1); + record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; + var remote = new GameWindow.RemoteMotion(); + RemotePhysicsBodyInitializer.Initialize(remote.Body, record); + fixture.Live.SetRemoteMotionRuntime(Guid, remote); + + PhysicsStateFlags ordinaryState = + PhysicsStateFlags.Gravity | PhysicsStateFlags.IgnoreCollisions; + record.FinalPhysicsState = ordinaryState; + Assert.False(fixture.Controller.ApplyAuthoritativeState( + Guid, ordinaryState, 1.0, 1, 1)); + + Assert.Equal(ordinaryState, remote.Body.State); + Assert.Null(record.ProjectileRuntime); + + fixture.ResolvedSetup = new Setup(); + PhysicsStateFlags unsupportedMissile = + ordinaryState | PhysicsStateFlags.Missile; + record.FinalPhysicsState = unsupportedMissile; + Assert.True(fixture.Controller.ApplyAuthoritativeState( + Guid, unsupportedMissile, 1.1, 1, 1)); + + Assert.Equal(unsupportedMissile, remote.Body.State); + Assert.Null(record.ProjectileRuntime); + Assert.Same(remote.Body, record.PhysicsBody); + } + + [Fact] + public void FirstMissileStateWithInvalidLocalClock_RetainsClassificationAndBindsOnce() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1); + record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; + + record.FinalPhysicsState = MissileState; + Assert.True(fixture.Controller.ApplyAuthoritativeState( + Guid, MissileState, double.NaN, 1, 1)); + + Assert.NotNull(record.ProjectileRuntime); + Assert.Equal(MissileState, record.PhysicsBody!.State); + Assert.True(double.IsFinite(record.PhysicsBody.LastUpdateTime)); + + ILiveEntityProjectileRuntime runtime = record.ProjectileRuntime!; + Assert.True(fixture.Controller.ApplyAuthoritativeState( + Guid, MissileState, 0.1, 1, 1)); + Assert.Same(runtime, record.ProjectileRuntime); + } + + [Fact] + public void FirstRemoteMissileStateWithInvalidLocalClock_AdoptsExistingBody() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1); + record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; + var remote = new GameWindow.RemoteMotion(); + RemotePhysicsBodyInitializer.Initialize(remote.Body, record); + fixture.Live.SetRemoteMotionRuntime(Guid, remote); + + record.FinalPhysicsState = MissileState; + Assert.True(fixture.Controller.ApplyAuthoritativeState( + Guid, MissileState, double.PositiveInfinity, 1, 1)); + + Assert.NotNull(record.ProjectileRuntime); + Assert.Same(remote.Body, record.ProjectileRuntime!.Body); + Assert.Equal(MissileState, remote.Body.State); + Assert.True(double.IsFinite(remote.Body.LastUpdateTime)); + } + + [Fact] + public void Bind_AppliesRetailFrictionValidationAndElasticityClamp() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn( + instance: 1, + friction: -0.1f, + elasticity: 2f); + + Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0)); + + Assert.Equal(PhysicsBody.DefaultFriction, record.PhysicsBody!.Friction); + Assert.Equal(0.1f, record.PhysicsBody.Elasticity); + } + + [Fact] + public void Bind_AcceptsRetailZeroFrictionBoundary() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn( + instance: 1, + friction: 0f, + elasticity: 0f); + + Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0)); + + Assert.Equal(0f, record.PhysicsBody!.Friction); + Assert.Equal(0f, record.PhysicsBody.Elasticity); + } + + [Fact] + public void RemoteMotionFirst_StateAcquisitionPreservesCurrentBodyAndCanonicalCell() + { + const uint startCell = 0xA9B40039u; + const uint destinationCell = 0xAAB40001u; + var fixture = new Fixture(loadInitialLandblock: false, BuildBoundaryEngine()); + fixture.Spatial.AddLandblock(EmptyLandblock(0xA9B4FFFFu)); + fixture.Spatial.AddLandblock(EmptyLandblock(0xAAB4FFFFu)); + LiveEntityRecord record = fixture.Spawn( + instance: 1, + cellId: startCell, + worldPosition: new Vector3(191f, 10f, 50f), + cellLocalPosition: new Vector3(191f, 10f, 50f), + velocity: new Vector3(40f, 0f, 0f), + angularVelocity: new Vector3(0f, 0f, 2f)); + WorldEntity entity = record.WorldEntity!; + fixture.Engine.ShadowObjects.Register( + entity.Id, + entity.SourceGfxObjOrSetupId, + entity.Position, + entity.Rotation, + radius: 0.5f, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: 0xA9B4FFFFu, + seedCellId: startCell, + isStatic: false); + record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; + var remote = new GameWindow.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); + remote.Body.set_velocity(new Vector3(8f, 0f, 0f)); + remote.Body.Omega = new Vector3(0f, 0f, 3f); + remote.Body.Position = entity.Position; + remote.Body.State = record.FinalPhysicsState; + fixture.Live.SetRemoteMotionRuntime(Guid, remote); + PhysicsBody body = remote.Body; + + record.FinalPhysicsState = MissileState; + Assert.True(fixture.Controller.ApplyAuthoritativeState( + Guid, + MissileState, + currentTime: 1.2, + liveCenterX: 0xA9, + liveCenterY: 0xB4)); + + Assert.Same(body, record.ProjectileRuntime!.Body); + Assert.Same(body, record.RemoteMotionRuntime!.Body); + Assert.Same(body, record.PhysicsBody); + Assert.Equal(new Vector3(8f, 0f, 0f), body.Velocity); + Assert.Equal(new Vector3(0f, 0f, 3f), body.Omega); + Assert.True(body.IsActive); + + fixture.Controller.Tick(1.45, 0xA9, 0xB4, playerWorldPosition: null); + Assert.Equal(destinationCell, record.FullCellId); + Assert.Equal(destinationCell, remote.CellId); + + record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; + Assert.True(fixture.Controller.ApplyAuthoritativeState( + Guid, record.FinalPhysicsState, 1.5, 0xA9, 0xB4)); + Assert.Equal(destinationCell, remote.CellId); + Assert.Equal(destinationCell, record.FullCellId); + + body.Position = new Vector3(191f, 10f, 50f); + entity.SetPosition(body.Position); + remote.CellId = startCell; + Assert.True(fixture.Controller.LeaveWorld(Guid)); + fixture.Controller.Tick(1.6, 0xA9, 0xB4, playerWorldPosition: null); + Assert.True(body.InWorld); + Assert.Contains( + fixture.Engine.ShadowObjects.GetObjectsInCell(startCell), + entry => entry.EntityId == entity.Id); + Assert.DoesNotContain( + fixture.Engine.ShadowObjects.GetObjectsInCell(destinationCell), + entry => entry.EntityId == entity.Id); + + record.FinalPhysicsState = MissileState; + Assert.True(fixture.Controller.ApplyAuthoritativeState( + Guid, MissileState, 1.7, 0xA9, 0xB4)); + Assert.Equal(startCell, record.FullCellId); + Assert.Equal(startCell, remote.CellId); + Assert.Equal(startCell, body.CellPosition.ObjCellId); + } + + [Fact] + public void RemoteMotionFirst_ValidatesAdoptedBodyInsteadOfStaleCreateVectors() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn( + instance: 1, + velocity: new Vector3(float.NaN, 0f, 0f)); + WorldEntity entity = record.WorldEntity!; + record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; + var remote = new GameWindow.RemoteMotion(); + RemotePhysicsBodyInitializer.Initialize(remote.Body, record); + remote.Body.Position = entity.Position; + remote.Body.Orientation = entity.Rotation; + fixture.Live.SetRemoteMotionRuntime(Guid, remote); + + Assert.True(float.IsNaN(record.Snapshot.Physics!.Value.Velocity!.Value.X)); + Assert.Equal(Vector3.Zero, remote.Body.Velocity); + + record.FinalPhysicsState = MissileState; + Assert.True(fixture.Controller.ApplyAuthoritativeState( + Guid, MissileState, 1.0, 1, 1)); + + Assert.NotNull(record.ProjectileRuntime); + Assert.Same(remote.Body, record.ProjectileRuntime!.Body); + Assert.Equal(Vector3.Zero, remote.Body.Velocity); + Assert.Equal(entity.Position, remote.Body.Position); + } + + [Fact] + public void RemoteMotionFirst_RejectsNonFiniteCurrentBodyDespiteValidCreateSnapshot() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1); + WorldEntity entity = record.WorldEntity!; + record.FinalPhysicsState = PhysicsStateFlags.ReportCollisions; + var remote = new GameWindow.RemoteMotion(); + RemotePhysicsBodyInitializer.Initialize(remote.Body, record); + remote.Body.Position = entity.Position; + remote.Body.Orientation = entity.Rotation; + remote.Body.Velocity = new Vector3(float.PositiveInfinity, 0f, 0f); + fixture.Live.SetRemoteMotionRuntime(Guid, remote); + + record.FinalPhysicsState = MissileState; + Assert.True(fixture.Controller.ApplyAuthoritativeState( + Guid, MissileState, 1.0, 1, 1)); + + Assert.Null(record.ProjectileRuntime); + Assert.Same(remote.Body, record.PhysicsBody); + Assert.True(float.IsPositiveInfinity(remote.Body.Velocity.X)); + Assert.Equal(MissileState, remote.Body.State); + } + + [Fact] + public void SharedRemoteMotionCell_DelegatesToCanonicalLiveRecord() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1); + Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0)); + PhysicsBody body = record.PhysicsBody!; + var remote = new GameWindow.RemoteMotion(body); + fixture.Live.SetRemoteMotionRuntime(Guid, remote); + + fixture.Live.RebucketLiveEntity(Guid, CellB); + Assert.Equal(CellB, remote.CellId); + + remote.CellId = CellA; + Assert.Equal(CellA, record.FullCellId); + Assert.Equal(CellA, remote.CellId); + Assert.Same(body, record.RemoteMotionRuntime!.Body); + Assert.Same(body, record.ProjectileRuntime!.Body); + + var replacement = new GameWindow.RemoteMotion(body); + fixture.Live.SetRemoteMotionRuntime(Guid, replacement); + remote.CellId = CellB; + Assert.Equal(CellA, record.FullCellId); + Assert.Equal(0u, remote.CellId); + replacement.CellId = CellB; + Assert.Equal(CellB, record.FullCellId); + } + + [Fact] + public void PickupLeaveWorldAndPositionReentry_RestoresShadowWithoutClockBacklog() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 4); + WorldEntity entity = record.WorldEntity!; + Assert.True(fixture.Controller.TryBind(record, ProjectileSetup(), 1.0)); + fixture.Engine.ShadowObjects.Register( + entity.Id, + entity.SourceGfxObjOrSetupId, + entity.Position, + entity.Rotation, + radius: 0.5f, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: CellA & 0xFFFF0000u, + seedCellId: CellA, + isStatic: false); + Assert.Contains( + fixture.Engine.ShadowObjects.GetObjectsInCell(CellA), + entry => entry.EntityId == entity.Id); + + Assert.True(fixture.Live.TryApplyPickup( + new PickupEvent.Parsed(Guid, InstanceSequence: 4, PositionSequence: 2), + out _)); + Assert.True(fixture.Live.WithdrawLiveEntityProjection(Guid)); + Assert.True(fixture.Controller.LeaveWorld(Guid)); + Assert.DoesNotContain( + fixture.Engine.ShadowObjects.GetObjectsInCell(CellA), + entry => entry.EntityId == entity.Id); + + var correction = new CreateObject.ServerPosition( + CellA, + 12f, + 10f, + 5f, + 1f, + 0f, + 0f, + 0f); + var update = new WorldSession.EntityPositionUpdate( + Guid, + correction, + Velocity: null, + PlacementId: null, + IsGrounded: true, + InstanceSequence: 4, + PositionSequence: 3, + TeleportSequence: 0, + ForcePositionSequence: 0); + Assert.True(fixture.Live.TryApplyPosition( + update, + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + out PositionTimestampDisposition disposition, + out WorldSession.EntitySpawn accepted, + out _)); + Assert.Equal(PositionTimestampDisposition.Apply, disposition); + Assert.Same( + entity, + fixture.Live.MaterializeLiveEntity( + Guid, + accepted.Position!.Value.LandblockId, + _ => throw new InvalidOperationException("re-entry recreated the entity"))); + + Assert.True(fixture.Controller.ApplyAuthoritativePosition( + Guid, + worldPosition: new Vector3(12f, 10f, 5f), + cellLocalPosition: new Vector3(12f, 10f, 5f), + orientation: Quaternion.Identity, + fullCellId: CellA, + currentTime: 10.0, + liveCenterX: 1, + liveCenterY: 1)); + Assert.Equal(10.0, record.PhysicsBody!.LastUpdateTime, 8); + Assert.Contains( + fixture.Engine.ShadowObjects.GetObjectsInCell(CellA), + entry => entry.EntityId == entity.Id); + + fixture.Controller.Tick( + 10.1, + liveCenterX: 1, + liveCenterY: 1, + playerWorldPosition: null); + Assert.Equal(13f, entity.Position.X, 3); + } + + [Fact] + public void DeleteAndGuidReuse_IsolateProjectileGenerations() + { + var fixture = new Fixture(); + LiveEntityRecord first = fixture.Spawn(instance: 7); + Assert.True(fixture.Controller.TryBind(first, ProjectileSetup(), 1.0)); + WorldEntity firstEntity = first.WorldEntity!; + ILiveEntityProjectileRuntime firstRuntime = first.ProjectileRuntime!; + + Assert.True(fixture.Live.UnregisterLiveEntity( + new DeleteObject.Parsed(Guid, InstanceSequence: 7), + isLocalPlayer: false)); + LiveEntityRecord second = fixture.Spawn(instance: 8); + Assert.True(fixture.Controller.TryBind(second, ProjectileSetup(), 2.0)); + + Assert.NotSame(firstEntity, second.WorldEntity); + Assert.NotSame(firstRuntime, second.ProjectileRuntime); + Assert.Equal(1, fixture.Controller.Count); + Assert.Equal(2, fixture.Resources.Registers); + Assert.Equal(1, fixture.Resources.Unregisters); + } + + [Fact] + public void MissingRetailSphere_FailsWithoutFabricatingCollisionBody() + { + var fixture = new Fixture(); + LiveEntityRecord record = fixture.Spawn(instance: 1); + string? diagnostic = null; + fixture.Controller.DiagnosticSink = text => diagnostic = text; + + Assert.False(fixture.Controller.TryBind(record, new Setup(), 1.0)); + + Assert.Null(record.ProjectileRuntime); + Assert.Null(record.PhysicsBody); + Assert.Contains("one-sphere", diagnostic); + } + + private sealed class Fixture + { + internal Fixture( + bool loadInitialLandblock = true, + PhysicsEngine? physicsEngine = null) + { + if (loadInitialLandblock) + Spatial.AddLandblock(EmptyLandblock(0x0101FFFFu)); + Live = new LiveEntityRuntime(Spatial, Resources); + Engine = physicsEngine ?? new PhysicsEngine(); + Controller = new ProjectileController( + Live, + Engine, + _ => ResolvedSetup, + entity => Poses.UpdateRoot(entity)); + } + + internal GpuWorldState Spatial { get; } = new(); + internal RecordingResources Resources { get; } = new(); + internal LiveEntityRuntime Live { get; } + internal PhysicsEngine Engine { get; } + internal EntityEffectPoseRegistry Poses { get; } = new(); + internal Setup ResolvedSetup { get; set; } = ProjectileSetup(); + internal ProjectileController Controller { get; } + + internal LiveEntityRecord Spawn( + ushort instance, + ushort vectorSequence = 1, + uint cellId = CellA, + Vector3? worldPosition = null, + Vector3? cellLocalPosition = null, + Vector3? velocity = null, + Vector3? angularVelocity = null, + float friction = 0f, + float elasticity = 0f, + bool hasRenderablePart = false) + { + Vector3 world = worldPosition ?? new Vector3(10f, 10f, 5f); + Vector3 cellLocal = cellLocalPosition ?? world; + WorldSession.EntitySpawn spawn = ProjectileSpawn( + instance, + vectorSequence, + cellId, + cellLocal, + velocity ?? new Vector3(10f, 0f, 0f), + angularVelocity ?? Vector3.Zero, + friction, + elasticity); + LiveEntityRegistrationResult registration = Live.RegisterLiveEntity(spawn); + Assert.NotNull(registration.Record); + WorldEntity? entity = Live.MaterializeLiveEntity( + Guid, + cellId, + localId => new WorldEntity + { + Id = localId, + ServerGuid = Guid, + SourceGfxObjOrSetupId = 0x02000124u, + Position = world, + Rotation = Quaternion.Identity, + MeshRefs = hasRenderablePart + ? new[] { new MeshRef(0x01000001u, Matrix4x4.Identity) } + : Array.Empty(), + ParentCellId = cellId, + }); + Assert.NotNull(entity); + Poses.PublishMeshRefs(entity); + return registration.Record!; + } + } + + private static Setup ProjectileSetup(float radius = 0.102f) => new() + { + Spheres = + { + new Sphere + { + Origin = new Vector3(0f, -0.165f, 0.1045f), + Radius = radius, + }, + }, + }; + + private static WorldSession.EntitySpawn ProjectileSpawn( + ushort instance, + ushort vectorSequence, + uint cellId, + Vector3 cellLocalPosition, + Vector3 velocity, + Vector3 angularVelocity, + float friction, + float elasticity) + { + var position = new CreateObject.ServerPosition( + cellId, + cellLocalPosition.X, + cellLocalPosition.Y, + cellLocalPosition.Z, + 1f, 0f, 0f, 0f); + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 1, + State: 1, + Vector: vectorSequence, + Teleport: 0, + ServerControlledMove: 1, + ForcePosition: 0, + ObjDesc: 1, + Instance: instance); + var physics = new PhysicsSpawnData( + RawState: (uint)MissileState, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000124u, + MotionTableId: null, + SoundTableId: null, + PhysicsScriptTableId: 0x3400002Bu, + Parent: null, + Children: null, + Scale: 1f, + Friction: friction, + Elasticity: elasticity, + Translucency: null, + Velocity: velocity, + Acceleration: new Vector3(999f, 999f, 999f), + AngularVelocity: angularVelocity, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + Guid, + position, + 0x02000124u, + Array.Empty(), + Array.Empty(), + Array.Empty(), + BasePaletteId: null, + ObjScale: 1f, + Name: "Projectile fixture", + ItemType: null, + MotionState: null, + MotionTableId: null, + PhysicsState: (uint)MissileState, + Friction: friction, + Elasticity: elasticity, + InstanceSequence: instance, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + + private static LoadedLandblock EmptyLandblock(uint canonicalId) => + new(canonicalId, new LandBlock(), Array.Empty()); + + private static PhysicsEngine BuildBoundaryEngine() + { + static TerrainSurface EmptyTerrain() + { + var heights = new byte[81]; + var table = new float[256]; + Array.Fill(table, -1000f); + return new TerrainSurface(heights, table); + } + + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + engine.AddLandblock( + 0xA9B4FFFFu, + EmptyTerrain(), + Array.Empty(), + Array.Empty(), + 0f, + 0f); + engine.AddLandblock( + 0xAAB4FFFFu, + EmptyTerrain(), + Array.Empty(), + Array.Empty(), + 192f, + 0f); + return engine; + } +} diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs index 222277c4..1f4556a2 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs @@ -93,6 +93,73 @@ public class ShadowObjectRegistryTests reg.Deregister(999u); } + [Fact] + public void Suspend_WithdrawsCellsAndUpdatePositionRestoresSameRegistration() + { + var reg = new ShadowObjectRegistry(); + const uint entityId = 23u; + reg.Register( + entityId, + 0x01000004u, + new Vector3(12f, 12f, 50f), + Quaternion.Identity, + 1f, + OffX, + OffY, + LbId); + + Assert.True(reg.Suspend(entityId)); + Assert.Empty(reg.GetObjectsInCell(LbId | 1u)); + Assert.Equal(0, reg.TotalRegistered); + + reg.UpdatePosition( + entityId, + new Vector3(36f, 12f, 50f), + Quaternion.Identity, + OffX, + OffY, + LbId, + seedCellId: LbId | 9u); + + ShadowEntry restored = Assert.Single(reg.GetObjectsInCell(LbId | 9u)); + Assert.Equal(entityId, restored.EntityId); + Assert.Equal(new Vector3(36f, 12f, 50f), restored.Position); + Assert.Equal(1, reg.TotalRegistered); + } + + [Fact] + public void Suspend_StateChangeUpdatesRetainedRegistrationBeforeRestore() + { + var reg = new ShadowObjectRegistry(); + const uint entityId = 24u; + reg.Register( + entityId, + 0x01000004u, + new Vector3(12f, 12f, 50f), + Quaternion.Identity, + 1f, + OffX, + OffY, + LbId, + state: 0x40u, + seedCellId: LbId | 1u, + isStatic: false); + Assert.True(reg.Suspend(entityId)); + + reg.UpdatePhysicsState(entityId, 0x44u); + reg.UpdatePosition( + entityId, + new Vector3(12f, 12f, 50f), + Quaternion.Identity, + OffX, + OffY, + LbId, + seedCellId: LbId | 1u); + + ShadowEntry restored = Assert.Single(reg.GetObjectsInCell(LbId | 1u)); + Assert.Equal(0x44u, restored.State); + } + // ----------------------------------------------------------------------- // RemoveLandblock // ----------------------------------------------------------------------- @@ -113,6 +180,33 @@ public class ShadowObjectRegistryTests Assert.Empty(reg.GetObjectsInCell(LbId | 1u)); } + [Fact] + public void RemoveLandblock_DynamicRegistrationRefloodsAfterStreamingReload() + { + var reg = new ShadowObjectRegistry(); + const uint entityId = 31u; + reg.Register( + entityId, + 0x01000005u, + new Vector3(12f, 12f, 50f), + Quaternion.Identity, + 1f, + OffX, + OffY, + LbId, + seedCellId: LbId | 1u, + isStatic: false); + + reg.RemoveLandblock(LbId); + Assert.Empty(reg.GetObjectsInCell(LbId | 1u)); + + reg.RefloodLandblock(LbId); + + Assert.Contains( + reg.GetObjectsInCell(LbId | 1u), + entry => entry.EntityId == entityId); + } + // ----------------------------------------------------------------------- // Per-cell query surface (BR-7 / A6.P4 2026-06-11): GetObjectsInCell IS // the query — retail CObjCell::find_obj_collisions iterates only the