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