feat(physics): integrate live projectile runtime

Attach the retail projectile driver to canonical LiveEntityRecord ownership, sharing one PhysicsBody and full-cell identity with RemoteMotion regardless of creation order. Apply timestamp-gated state, vector, and position corrections in place, preserve active ordinary-body behavior when Missile clears, and keep renderer, effects, shadows, and spatial buckets synchronized across loaded/pending transitions.

Validate malformed packets before canonical timestamp mutation, validate adopted bodies from their current frame rather than stale CreateObject data, serialize late Setup resolution with streaming DAT reads, and preserve classification across clock anomalies. Retain shadow registrations through temporary leave-world residence while logical teardown remains generation-scoped.

Add 31 App lifecycle/adversarial tests plus Core shadow suspension coverage, and synchronize the retail pseudocode, architecture, milestones, roadmap, and durable physics memory.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-14 12:51:42 +02:00
parent f02b995e4f
commit a51ebc66e9
12 changed files with 2559 additions and 32 deletions

View file

@ -179,6 +179,8 @@ src/
ISelectionService.cs -> done ISelectionService.cs -> done
AcDream.App/ Layer 1 + Layer 4 wiring AcDream.App/ Layer 1 + Layer 4 wiring
Physics/
ProjectileController.cs -> live-record projectile orchestration/corrections
Rendering/ Rendering/
GameWindow.cs -> still owns too much runtime wiring GameWindow.cs -> still owns too much runtime wiring
TerrainRenderer.cs -> done TerrainRenderer.cs -> done
@ -224,6 +226,28 @@ What exists and is active:
AlignPath, Setup-sphere transition sweeps, and collision response. It owns no AlignPath, Setup-sphere transition sweeps, and collision response. It owns no
live entity, network, rendering, or App state; the App controller supplies live entity, network, rendering, or App state; the App controller supplies
those boundaries. 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 - `BSPQuery` contains the partial retail-style BSP collision dispatcher used by
the transition path. the transition path.
- `TransitionTypes` carries `SpherePath`, `CollisionInfo`, `ObjectInfo`, - `TransitionTypes` carries `SpherePath`, `CollisionInfo`, `ObjectInfo`,

View file

@ -516,6 +516,7 @@ behavior. Estimated 1726 days focused work, 35 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. - ~~**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 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. **Reference docs:** `docs/research/retail-ui/00-master-synthesis.md` + slices 01-06. Every AC-specific behavior has a decompiled FUN_ / DAT_ citation.

View file

@ -471,7 +471,7 @@ include dungeons.
`PlayerDescription.Options1` preserves retail's player secure-trade `PlayerDescription.Options1` preserves retail's player secure-trade
preference. See preference. See
`docs/research/2026-07-13-retail-give-item-pseudocode.md` and issue #216. `docs/research/2026-07-13-retail-give-item-pseudocode.md` and issue #216.
- **M2/M3 missile, effect, and portal presentation campaign (Steps 06 - **M2/M3 missile, effect, and portal presentation campaign (Steps 07
implemented and independently reviewed 2026-07-14)** — named-retail projectile and implemented and independently reviewed 2026-07-14)** — named-retail projectile and
physics-script behavior is pinned in one oracle, the complete `PhysicsDesc` physics-script behavior is pinned in one oracle, the complete `PhysicsDesc`
and F754/F755 wire surfaces are parsed with retail timestamp gates, and 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, Setup-local sphere sweeps, exact missile target/ethereal exclusions,
self-shadow rejection, retail collision response, and full-cell landblock self-shadow rejection, retail collision response, and full-cell landblock
rebasing. Missile adds PathClipped but never PerfectClip. AP-67, TS-2, TS-10, 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 TS-11, and TS-12 are retired; AP-83/AP-91 remain dormant. Step 7 attaches
steps integrate live projectile ownership/corrections and port Hidden/UnHide that primitive to the canonical `LiveEntityRecord`: arrows, bolts, and spell
portal presentation. 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)** - **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 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 funnel and action-stamp gate, so ACE's server-selected melee/missile action

View file

@ -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. Boundary conformance covers positive and negative X and Y crossings.
- TS-2 is retired. AP-83 and AP-91 remain because ordinary missiles are - 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. 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.

View file

@ -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;
/// <summary>
/// Update-thread presentation owner for live retail missiles. Logical identity,
/// generation, accepted network state, and teardown remain owned by
/// <see cref="LiveEntityRuntime"/>; this controller only advances the
/// <see cref="PhysicsBody"/> attached to that canonical record and commits its
/// frame through the existing <see cref="WorldEntity"/> projection.
/// </summary>
/// <remarks>
/// Retail anchors are <c>CPhysicsObj::update_object</c> <c>0x00515D10</c>,
/// <c>CPhysicsObj::UpdateObjectInternal</c> <c>0x005156B0</c>, and
/// <c>CPhysicsObj::SetPositionInternal</c> <c>0x00515330</c>. The pure
/// integration/sweep is implemented by <see cref="ProjectilePhysicsStepper"/>.
/// ACE remains authoritative for collision outcomes, damage, effects, and
/// deletion; no such events are synthesized here.
/// </remarks>
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<uint, Setup?> _setupResolver;
private readonly Action<WorldEntity> _publishRootPose;
private double _lastFiniteGameTime;
internal ProjectileController(
LiveEntityRuntime liveEntities,
PhysicsEngine physicsEngine,
Func<uint, Setup?>? setupResolver = null,
Action<WorldEntity>? 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<string>? 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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
}

View file

@ -0,0 +1,39 @@
using System.Numerics;
using AcDream.App.World;
using AcDream.Core.Physics;
namespace AcDream.App.Physics;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Retail <c>CPhysicsObj::set_description</c> <c>0x00514F40</c> installs the
/// unpacked velocity/omega during object initialization. Retail
/// <c>SmartBox::DoSetState</c> <c>0x004520D0</c> only calls
/// <c>CPhysicsObj::set_state</c> and therefore preserves the current vectors.
/// </remarks>
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);
}

View file

@ -159,6 +159,9 @@ public sealed class GameWindow : IDisposable
// once the injected shared helpers exist as method groups (GetSetupCylinder, // once the injected shared helpers exist as method groups (GetSetupCylinder,
// ApplyServerControlledVelocityCycle). See RemotePhysicsUpdater. // ApplyServerControlledVelocityCycle). See RemotePhysicsUpdater.
private readonly AcDream.App.Physics.RemotePhysicsUpdater _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. // Step 4: portal-based interior cell visibility.
private readonly CellVisibility _cellVisibility = new(); private readonly CellVisibility _cellVisibility = new();
@ -456,7 +459,9 @@ public sealed class GameWindow : IDisposable
/// remote gets the same treatment as the local player. /// remote gets the same treatment as the local player.
/// </para> /// </para>
/// </summary> /// </summary>
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; public AcDream.Core.Physics.PhysicsBody Body;
AcDream.Core.Physics.PhysicsBody AcDream.App.World.ILiveEntityRemoteMotionRuntime.Body => 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 /// UP lands, which disables the transition step for that frame
/// (Euler alone, matching pre-wire behavior). /// (Euler alone, matching pre-wire behavior).
/// </summary> /// </summary>
public uint CellId; private uint _cellId;
private Func<uint>? _canonicalCellReader;
private Action<uint>? _canonicalCellWriter;
/// <summary>
/// Canonical full cell for this one live CPhysicsObj. Ordinary remotes
/// retain the local backing value. A MovementManager attached to an
/// existing projectile delegates reads and writes to
/// <see cref="LiveEntityRuntime"/>, so projectile prediction,
/// Movement packets, and spatial rebucketing cannot develop competing
/// cell identities.
/// </summary>
public uint CellId
{
get => _canonicalCellReader?.Invoke() ?? _cellId;
set
{
_cellId = value;
_canonicalCellWriter?.Invoke(value);
}
}
/// <summary> /// <summary>
/// K-fix9 (2026-04-26): true while the remote is airborne (jump /// K-fix9 (2026-04-26): true while the remote is airborne (jump
@ -657,9 +682,9 @@ public sealed class GameWindow : IDisposable
/// </summary> /// </summary>
public float MaxSeqSpeedSinceLastUP; 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 // Remotes don't simulate gravity — server owns Z. Force
// Contact + OnWalkable + Active so apply_current_movement // Contact + OnWalkable + Active so apply_current_movement
@ -692,6 +717,12 @@ public sealed class GameWindow : IDisposable
WeenieObj = new AcDream.Core.Physics.RemoteWeenie(), WeenieObj = new AcDream.Core.Physics.RemoteWeenie(),
}); });
} }
public void BindCanonicalCell(Func<uint> read, Action<uint> write)
{
_canonicalCellReader = read ?? throw new ArgumentNullException(nameof(read));
_canonicalCellWriter = write ?? throw new ArgumentNullException(nameof(write));
}
} }
/// <summary>Soft-snap decay rate (1/sec). At this rate the residual /// <summary>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) if (record.WorldEntity is { } entity)
_particleSink!.SetEntityPresentationVisible(entity.Id, visible); _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<DatReaderWriter.DBObjs.Setup>(setupId);
},
entity => _effectPoses.UpdateRoot(entity))
{
DiagnosticSink = message => Console.Error.WriteLine($"projectile: {message}"),
};
_liveEntityLights = new AcDream.App.Rendering.Vfx.LiveEntityLightController( _liveEntityLights = new AcDream.App.Rendering.Vfx.LiveEntityLightController(
_liveEntities, _liveEntities,
@ -3143,8 +3190,8 @@ public sealed class GameWindow : IDisposable
uint canonical = (loadedLandblockId & 0xFFFF0000u) | 0xFFFFu; uint canonical = (loadedLandblockId & 0xFFFF0000u) | 0xFFFFu;
LiveEntityRecord[] records = _liveEntities.Records LiveEntityRecord[] records = _liveEntities.Records
.Where(record => record.ServerGuid != _playerServerGuid .Where(record => record.ServerGuid != _playerServerGuid
&& record.Snapshot.Position is { } position && record.ProjectionCellId != 0
&& ((position.LandblockId & 0xFFFF0000u) | 0xFFFFu) == canonical && ((record.ProjectionCellId & 0xFFFF0000u) | 0xFFFFu) == canonical
&& record.Snapshot.SetupTableId is not null) && record.Snapshot.SetupTableId is not null)
.ToArray(); .ToArray();
if (records.Length == 0) return; if (records.Length == 0) return;
@ -3162,7 +3209,7 @@ public sealed class GameWindow : IDisposable
{ {
if (_liveEntities.RebucketLiveEntity( if (_liveEntities.RebucketLiveEntity(
record.ServerGuid, record.ServerGuid,
record.Snapshot.Position!.Value.LandblockId)) record.ProjectionCellId))
projected++; projected++;
} }
else else
@ -3906,6 +3953,13 @@ public sealed class GameWindow : IDisposable
// falls through to OK_TS for any object with no collision // falls through to OK_TS for any object with no collision
// geometry (acclient_2013_pseudo_c.txt:276917,276987). // geometry (acclient_2013_pseudo_c.txt:276917,276987).
RegisterLiveEntityCollision(entity, setup, spawn, origin); 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 // Phase B.2: capture the server-sent MotionTableId for our own
// character so UpdatePlayerAnimation can pass it to GetIdleCycle. // character so UpdatePlayerAnimation can pass it to GetIdleCycle.
@ -4923,6 +4977,9 @@ public sealed class GameWindow : IDisposable
_translucencyFades.ClearEntity(existingEntity.Id); // #188 _translucencyFades.ClearEntity(existingEntity.Id); // #188
LeaveWorldLiveEntityRuntimeComponents(record); 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); _liveEntityLights?.Forget(existingEntity.Id);
} }
@ -4938,8 +4995,11 @@ public sealed class GameWindow : IDisposable
if (record.WorldEntity is not { } entity) if (record.WorldEntity is not { } entity)
return; return;
bool retainedProjectileShadow =
_projectileController?.LeaveWorld(record.ServerGuid) == true;
_worldGameState.RemoveById(entity.Id); _worldGameState.RemoveById(entity.Id);
_physicsEngine.ShadowObjects.Deregister(entity.Id); if (!retainedProjectileShadow)
_physicsEngine.ShadowObjects.Deregister(entity.Id);
if (record.ServerGuid == _playerServerGuid) if (record.ServerGuid == _playerServerGuid)
_lastLocalPlayerShadow = null; _lastLocalPlayerShadow = null;
@ -5429,7 +5489,7 @@ public sealed class GameWindow : IDisposable
// rm on first UM exactly like the UP handler does. // rm on first UM exactly like the UP handler does.
if (!_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot)) if (!_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot))
{ {
remoteMot = new RemoteMotion(); remoteMot = CreateRemoteMotion(update.Guid);
remoteMot.Body.Orientation = entity.Rotation; remoteMot.Body.Orientation = entity.Rotation;
remoteMot.Body.Position = entity.Position; remoteMot.Body.Position = entity.Position;
_remoteDeadReckon[update.Guid] = remoteMot; _remoteDeadReckon[update.Guid] = remoteMot;
@ -5591,16 +5651,48 @@ public sealed class GameWindow : IDisposable
/// LoginComplete so the server knows the client has loaded the destination. /// LoginComplete so the server knows the client has loaded the destination.
/// </summary> /// </summary>
/// <summary> /// <summary>
/// K-fix9 (2026-04-26): handle 0xF74E VectorUpdate — ACE broadcasts /// Creates the optional MovementManager companion for one live object.
/// this on remote-player JUMPS (Player.cs:954). The payload carries /// A retained projectile contributes the already-owned CPhysicsObj body
/// the world-space launch velocity. Without handling it, remote /// and canonical live-record cell; an ordinary remote gets local storage.
/// jumps render as a tiny lift-and-back because we never see the /// </summary>
/// +Z velocity that would integrate into a proper arc. 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);
}
/// <summary>
/// K-fix9 (2026-04-26): handle 0xF74E VectorUpdate from remote jumps.
/// The payload seeds the world-space launch velocity and angular velocity.
/// </summary> /// </summary>
private void OnLiveVectorUpdated(AcDream.Core.Net.Messages.VectorUpdate.Parsed update) 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 (!_liveEntities!.TryApplyVector(update, out _)) return;
if (_projectileController?.ApplyAuthoritativeVector(
update.Guid,
update.Velocity,
update.Omega,
_physicsScriptGameTime) == true)
return;
if (!_entitiesByServerGuid.ContainsKey(update.Guid)) return; if (!_entitiesByServerGuid.ContainsKey(update.Guid)) return;
if (update.Guid == _playerServerGuid) return; // local jump uses our own physics 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 // World-space velocity. Apply directly to the body — the per-tick
// remote update will integrate Position += Velocity × dt + 0.5 × Accel × dt². // 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 // L.3.1 Task 6: apply Omega too. Was parsed but ignored, leaving
// remote jumping/turning arcs flat. Mirrors retail SmartBox:: // remote jumping/turning arcs flat. Mirrors retail SmartBox::
@ -5647,7 +5739,7 @@ public sealed class GameWindow : IDisposable
{ {
EnsureRemoteMotionBindings(rm, ae, update.Guid); EnsureRemoteMotionBindings(rm, ae, update.Guid);
rm.Motion.LeaveGround(); rm.Motion.LeaveGround();
rm.Body.Velocity = update.Velocity; rm.Body.set_velocity(update.Velocity);
rm.Body.Omega = update.Omega; rm.Body.Omega = update.Omega;
} }
} }
@ -5671,6 +5763,12 @@ public sealed class GameWindow : IDisposable
{ {
if (!_liveEntities!.TryApplyState(parsed, out _)) return; if (!_liveEntities!.TryApplyState(parsed, out _)) return;
_projectileController?.ApplyAuthoritativeState(
parsed.Guid,
(AcDream.Core.Physics.PhysicsStateFlags)parsed.PhysicsState,
_physicsScriptGameTime,
_liveCenterX,
_liveCenterY);
_liveEntityLights?.OnStateChanged(parsed.Guid); _liveEntityLights?.OnStateChanged(parsed.Guid);
if (!_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity)) return; 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) private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
{ {
if (_projectileController?.CanAcceptPositionPayload(
update.Guid,
update.Position,
update.Velocity) == false)
return;
bool known = _liveEntities!.TryApplyPosition( bool known = _liveEntities!.TryApplyPosition(
update, update,
isLocalPlayer: update.Guid == _playerServerGuid, 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); : new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
DumpMovementTruthServerEcho(update, worldPos); 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 // Capture the pre-update render position for the soft-snap residual
// calculation below. Assign entity.Position to the server truth up // calculation below. Assign entity.Position to the server truth up
// front; if we then compute a snap residual, we restore the rendered // front; if we then compute a snap residual, we restore the rendered
@ -5956,7 +6077,7 @@ public sealed class GameWindow : IDisposable
// dispatcher). // dispatcher).
if (!_remoteDeadReckon.TryGetValue(update.Guid, out var rmState)) if (!_remoteDeadReckon.TryGetValue(update.Guid, out var rmState))
{ {
rmState = new RemoteMotion(); rmState = CreateRemoteMotion(update.Guid);
_remoteDeadReckon[update.Guid] = rmState; _remoteDeadReckon[update.Guid] = rmState;
// Hard-snap orientation on first spawn so the per-tick // Hard-snap orientation on first spawn so the per-tick
// slerp doesn't visibly rotate from Identity to truth. // 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 // Re-publish without advancing so an extra render between updates still
// retains retail's animation-hook versus object-hook phase barrier. // retains retail's animation-hook versus object-hook phase barrier.
_scriptRunner?.PublishTime(_physicsScriptGameTime); _scriptRunner?.PublishTime(_physicsScriptGameTime);
_projectileController?.Tick(
_physicsScriptGameTime,
_liveCenterX,
_liveCenterY,
_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var projectileViewer)
? projectileViewer.Position
: null);
if (_animatedEntities.Count > 0) if (_animatedEntities.Count > 0)
TickAnimations((float)deltaSeconds); TickAnimations((float)deltaSeconds);
_equippedChildRenderer?.Tick(); _equippedChildRenderer?.Tick();
@ -10322,6 +10450,7 @@ public sealed class GameWindow : IDisposable
&& serverGuid != 0 && serverGuid != 0
&& serverGuid != _playerServerGuid && serverGuid != _playerServerGuid
&& _remoteDeadReckon.TryGetValue(serverGuid, out var rm) && _remoteDeadReckon.TryGetValue(serverGuid, out var rm)
&& _projectileController?.HandlesMovement(serverGuid) != true
// R4-V5 door fix companion: rm entries now also exist for // R4-V5 door fix companion: rm entries now also exist for
// static animated objects (doors/levers — created on first // static animated objects (doors/levers — created on first
// UM so the funnel can play their On/Off cycles). Those // UM so the funnel can play their On/Off cycles). Those

View file

@ -21,8 +21,22 @@ public interface ILiveEntityRemoteMotionRuntime
PhysicsBody Body { get; } PhysicsBody Body { get; }
} }
/// <summary>Marker for the projectile runtime added by the projectile phase.</summary> /// <summary>
public interface ILiveEntityProjectileRuntime { } /// Optional seam for a remote-motion component that consumes the canonical
/// full-cell identity owned by <see cref="LiveEntityRecord"/>. The runtime
/// binds this for every production remote, regardless of whether projectile
/// classification arrives before or after the MovementManager.
/// </summary>
public interface ILiveEntityCanonicalCellConsumer
{
void BindCanonicalCell(Func<uint> read, Action<uint> write);
}
/// <summary>Projectile physics state owned by a live object.</summary>
public interface ILiveEntityProjectileRuntime
{
PhysicsBody Body { get; }
}
/// <summary>Marker for the DAT-driven effect profile added by the effect phase.</summary> /// <summary>Marker for the DAT-driven effect profile added by the effect phase.</summary>
public interface ILiveEntityEffectProfile { } public interface ILiveEntityEffectProfile { }
@ -96,6 +110,15 @@ public sealed class LiveEntityRecord
public bool WorldSpawnPublished { get; internal set; } public bool WorldSpawnPublished { get; internal set; }
public LiveEntityProjectionKind ProjectionKind { get; internal set; } public LiveEntityProjectionKind ProjectionKind { get; internal set; }
/// <summary>
/// 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.
/// </summary>
public uint ProjectionCellId => WorldEntity is not null
? FullCellId
: Snapshot.Position?.LandblockId ?? FullCellId;
internal void RefreshDerivedState(bool refreshPosition = true) internal void RefreshDerivedState(bool refreshPosition = true)
{ {
if (refreshPosition && Snapshot.Position is { } position) if (refreshPosition && Snapshot.Position is { } position)
@ -480,8 +503,38 @@ public sealed class LiveEntityRuntime
ArgumentNullException.ThrowIfNull(runtime); ArgumentNullException.ThrowIfNull(runtime);
if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record)) if (!_recordsByGuid.TryGetValue(serverGuid, out LiveEntityRecord? record))
throw new InvalidOperationException($"Cannot bind remote motion before live entity 0x{serverGuid:X8} exists."); 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.RemoteMotionRuntime = runtime;
record.PhysicsBody = runtime.Body; 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; _remoteMotionByGuid[serverGuid] = runtime;
} }
@ -495,7 +548,61 @@ public sealed class LiveEntityRuntime
return false; return false;
_remoteMotionByGuid.Remove(serverGuid); _remoteMotionByGuid.Remove(serverGuid);
record.RemoteMotionRuntime = null; 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; return true;
} }

View file

@ -198,6 +198,9 @@ public sealed class ProjectilePhysicsStepper
body.Position = beginPosition; body.Position = beginPosition;
body.Orientation = beginOrientation; body.Orientation = beginOrientation;
ObjectInfoState moverFlags = body.State.HasFlag(PhysicsStateFlags.PathClipped)
? ObjectInfoState.PathClipped
: ObjectInfoState.None;
var resolved = _physics.ResolveWithTransition( var resolved = _physics.ResolveWithTransition(
beginPosition, beginPosition,
candidatePosition, candidatePosition,
@ -208,7 +211,7 @@ public sealed class ProjectilePhysicsStepper
stepDownHeight: 0f, stepDownHeight: 0f,
isOnGround: previousOnWalkable, isOnGround: previousOnWalkable,
body: body, body: body,
moverFlags: ObjectInfoState.PathClipped, moverFlags: moverFlags,
movingEntityId: movingEntityId, movingEntityId: movingEntityId,
localSphereOrigin: sphere.LocalOrigin, localSphereOrigin: sphere.LocalOrigin,
beginOrientation: beginOrientation, beginOrientation: beginOrientation,

View file

@ -28,6 +28,7 @@ public sealed class ShadowObjectRegistry
{ {
private readonly Dictionary<uint, List<ShadowEntry>> _cells = new(); private readonly Dictionary<uint, List<ShadowEntry>> _cells = new();
private readonly Dictionary<uint, List<uint>> _entityToCells = new(); // for deregistration private readonly Dictionary<uint, List<uint>> _entityToCells = new(); // for deregistration
private readonly HashSet<uint> _suspendedEntities = new();
/// <summary> /// <summary>
/// A6.P4 door fix (2026-05-24): per-entity original shape list, used by /// 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); reg.State, reg.Flags, seedCellId, reg.IsStatic);
} }
/// <summary>
/// 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
/// <c>CPhysicsObj::remove_shadows_from_cells</c> during temporary
/// leave-world/pending-cell residence; it is deliberately not logical
/// teardown.
/// </summary>
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;
}
/// <summary> /// <summary>
/// BR-7 streaming hook — re-run the flood for every entity whose seed /// BR-7 streaming hook — re-run the flood for every entity whose seed
/// cell or current cell set touches <paramref name="landblockId"/>'s /// cell or current cell set touches <paramref name="landblockId"/>'s
@ -343,6 +371,9 @@ public sealed class ShadowObjectRegistry
foreach (var kvp in _entityReg) foreach (var kvp in _entityReg)
{ {
if (_suspendedEntities.Contains(kvp.Key))
continue;
if ((kvp.Value.SeedCellId & 0xFFFF0000u) == lbPrefix) if ((kvp.Value.SeedCellId & 0xFFFF0000u) == lbPrefix)
{ {
toReflood.Add(kvp.Key); toReflood.Add(kvp.Key);
@ -407,6 +438,11 @@ public sealed class ShadowObjectRegistry
/// </summary> /// </summary>
public void UpdatePhysicsState(uint entityId, uint newState) 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)) if (!_entityToCells.TryGetValue(entityId, out var cellIds))
return; // not registered — no-op return; // not registered — no-op
@ -438,15 +474,17 @@ public sealed class ShadowObjectRegistry
} }
_entityShapes.Remove(entityId); _entityShapes.Remove(entityId);
_entityReg.Remove(entityId); _entityReg.Remove(entityId);
_suspendedEntities.Remove(entityId);
} }
/// <summary> /// <summary>
/// Remove all entities belonging to a landblock. With flood-driven /// Remove all entities belonging to a landblock. With flood-driven
/// registration an entity's cells can span landblock prefixes; entries /// registration an entity's cells can span landblock prefixes, so entries
/// under OTHER prefixes survive, and the entity is fully dropped only /// under other prefixes survive. A static registration ends when its final
/// when no cells remain (its owner despawns it via /// streamed cell disappears. A dynamic/server-live registration retains
/// <see cref="Deregister"/> in the normal path — retail's per-object /// its exact payload with zero cell rows so a later load can reflood it;
/// remove_shadows_from_cells, Ghidra 0x00511230). /// only its logical owner calls <see cref="Deregister"/> (retail's
/// per-object remove_shadows_from_cells, Ghidra 0x00511230).
/// </summary> /// </summary>
public void RemoveLandblock(uint landblockId) public void RemoveLandblock(uint landblockId)
{ {
@ -473,8 +511,16 @@ public sealed class ShadowObjectRegistry
foreach (var eid in entitiesToRemove) foreach (var eid in entitiesToRemove)
{ {
_entityToCells.Remove(eid); _entityToCells.Remove(eid);
_entityShapes.Remove(eid); // A streamed-out server-live object is still logically alive.
_entityReg.Remove(eid); // 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);
}
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -93,6 +93,73 @@ public class ShadowObjectRegistryTests
reg.Deregister(999u); 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 // RemoveLandblock
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@ -113,6 +180,33 @@ public class ShadowObjectRegistryTests
Assert.Empty(reg.GetObjectsInCell(LbId | 1u)); 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 // Per-cell query surface (BR-7 / A6.P4 2026-06-11): GetObjectsInCell IS
// the query — retail CObjCell::find_obj_collisions iterates only the // the query — retail CObjCell::find_obj_collisions iterates only the