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

@ -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.
/// </para>
/// </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;
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).
/// </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>
/// K-fix9 (2026-04-26): true while the remote is airborne (jump
@ -657,9 +682,9 @@ public sealed class GameWindow : IDisposable
/// </summary>
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<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
@ -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<DatReaderWriter.DBObjs.Setup>(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.
/// </summary>
/// <summary>
/// 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.
/// </summary>
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>
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