From a51ebc66e92f270299bb52994d45ec3534351b79 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 14 Jul 2026 12:51:42 +0200 Subject: [PATCH] feat(physics): integrate live projectile runtime Attach the retail projectile driver to canonical LiveEntityRecord ownership, sharing one PhysicsBody and full-cell identity with RemoteMotion regardless of creation order. Apply timestamp-gated state, vector, and position corrections in place, preserve active ordinary-body behavior when Missile clears, and keep renderer, effects, shadows, and spatial buckets synchronized across loaded/pending transitions. Validate malformed packets before canonical timestamp mutation, validate adopted bodies from their current frame rather than stale CreateObject data, serialize late Setup resolution with streaming DAT reads, and preserve classification across clock anomalies. Retain shadow registrations through temporary leave-world residence while logical teardown remains generation-scoped. Add 31 App lifecycle/adversarial tests plus Core shadow suspension coverage, and synchronize the retail pseudocode, architecture, milestones, roadmap, and durable physics memory. Co-Authored-By: Codex --- docs/architecture/acdream-architecture.md | 24 + docs/plans/2026-04-11-roadmap.md | 1 + docs/plans/2026-05-12-milestones.md | 18 +- ...-07-13-retail-projectile-vfx-pseudocode.md | 102 ++ .../Physics/ProjectileController.cs | 744 ++++++++++ .../Physics/RemotePhysicsBodyInitializer.cs | 39 + src/AcDream.App/Rendering/GameWindow.cs | 163 ++- src/AcDream.App/World/LiveEntityRuntime.cs | 113 +- .../Physics/ProjectilePhysicsStepper.cs | 5 +- .../Physics/ShadowObjectRegistry.cs | 60 +- .../Physics/ProjectileControllerTests.cs | 1228 +++++++++++++++++ .../Physics/ShadowObjectRegistryTests.cs | 94 ++ 12 files changed, 2559 insertions(+), 32 deletions(-) create mode 100644 src/AcDream.App/Physics/ProjectileController.cs create mode 100644 src/AcDream.App/Physics/RemotePhysicsBodyInitializer.cs create mode 100644 tests/AcDream.App.Tests/Physics/ProjectileControllerTests.cs 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