feat(net): port retail physics spawn and event timestamps

Parse the complete PhysicsDesc plus F754/F755 packets, correct every PhysicsState bit, and gate all nine retail update channels with generation-safe immutable snapshots. Preserve ForcePosition, teleport, placement, velocity, parent, pickup, delete, and same-generation CreateObject ordering from the named client.

Separate accepted logical lifecycle notifications from retained UI qualities, make GUID replacement and session reset clear every projection exactly once, and add packet, wraparound, malformed-input, parent FIFO, canonical-position, reconnect, and GUID-reuse conformance coverage.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-14 00:22:17 +02:00
parent d53fe30ffe
commit 8a5d77f7f4
50 changed files with 3809 additions and 649 deletions

View file

@ -21,6 +21,18 @@ public readonly record struct MoveRequestFailure(
uint WeenieError,
bool RolledBack);
public enum ClientObjectRemovalReason
{
Ordinary,
LogicalDelete,
GenerationReplacement,
}
public readonly record struct ClientObjectRemoval(
ClientObject Object,
ClientObjectRemovalReason Reason,
ushort Generation);
/// <summary>
/// The client's table of every server object (retail <c>weenie_object_table</c> /
/// <c>CObjectMaint</c>). Resolve by guid via <c>Get</c>.
@ -103,6 +115,13 @@ public sealed class ClientObjectTable
/// <summary>Fires when an object is removed from the session.</summary>
public event Action<ClientObject>? ObjectRemoved;
/// <summary>
/// Generation-aware removal stream for runtime owners. UI consumers keep
/// using <see cref="ObjectRemoved"/>; runtime owners use the reason and
/// generation to preserve only packets addressed to future incarnations.
/// </summary>
public event Action<ClientObjectRemoval>? ObjectRemovalClassified;
/// <summary>Fires when an object's properties are updated (typically after Appraise).</summary>
public event Action<ClientObject>? ObjectUpdated;
@ -321,15 +340,47 @@ public sealed class ClientObjectTable
/// space, stolen, etc).
/// </summary>
public bool Remove(uint itemId)
=> RemoveCore(itemId, ClientObjectRemovalReason.Ordinary, 0, notifyObjectRemoved: true);
/// <summary>Removes an exact accepted server object incarnation.</summary>
public bool RemoveLogicalGeneration(uint itemId, ushort generation)
=> RemoveCore(
itemId,
ClientObjectRemovalReason.LogicalDelete,
generation,
notifyObjectRemoved: true);
private bool RemoveCore(
uint itemId,
ClientObjectRemovalReason reason,
ushort generation,
bool notifyObjectRemoved)
{
if (!_objects.TryRemove(itemId, out var item)) return false;
if (item.ContainerId != 0 && _containerIndex.TryGetValue(item.ContainerId, out var l))
l.Remove(itemId);
_pendingMoves.Remove(itemId); // a destroyed item must not leave a snapshot that mis-rolls-back a recycled guid
ObjectRemoved?.Invoke(item);
if (notifyObjectRemoved)
ObjectRemoved?.Invoke(item);
ObjectRemovalClassified?.Invoke(new ClientObjectRemoval(item, reason, generation));
return true;
}
/// <summary>
/// Atomically replaces the retained qualities for a newer server object
/// incarnation while publishing a generation-specific teardown event.
/// </summary>
public ClientObject ReplaceGeneration(WeenieData data, ushort generation)
{
RemoveCore(
data.Guid,
ClientObjectRemovalReason.GenerationReplacement,
generation,
notifyObjectRemoved: true);
return Ingest(data);
}
/// <summary>
/// Apply a <see cref="PropertyBundle"/> patch (e.g. from an
/// <c>IdentifyObjectResponse</c>) to an existing object. Individual

View file

@ -1,119 +0,0 @@
using System;
namespace AcDream.Core.Physics;
/// <summary>
/// Per-entity staleness gate for inbound movement events (0xF74C), ported
/// from retail (L.2g S1, closes deviation DEV-6 for the UM path).
///
/// <para>Retail keeps a <c>update_times[NUM_PHYSICS_TS]</c> array of u16
/// stamps on every <c>CPhysicsObj</c> (acclient.h:6084 —
/// <c>PhysicsTimeStamp</c>) and rejects out-of-order network events with a
/// wraparound-aware compare. Three of those stamps gate movement events:</para>
///
/// <list type="bullet">
/// <item><b>INSTANCE_TS (8)</b> — checked at dispatch
/// (<c>ACSmartBox::DispatchSmartBoxEvent</c> case 0xF74C,
/// acclient_2013_pseudo_c.txt:357214-357239): an event stamped with an
/// OLDER object incarnation than the one we know is dropped before any
/// other stamp is touched. Retail additionally QUEUES events for a NEWER
/// incarnation (<c>SmartBox::QueueBlobForObject</c>) until that object
/// version exists; acdream adopts-and-applies instead — register row
/// AD-32 records the divergence.</item>
/// <item><b>MOVEMENT_TS (1)</b> — <c>CPhysics::SetObjectMovement</c>
/// (0x00509690, acclient_2013_pseudo_c.txt:271370) applies an event only
/// when its movement sequence is STRICTLY newer than the stored stamp
/// (equal = duplicate delivery = drop), and stamps BEFORE evaluating the
/// server-control gate — a movement sequence is consumed even when the
/// event is subsequently dropped for stale server control.</item>
/// <item><b>SERVER_CONTROLLED_MOVE_TS (5)</b> — same function: the event is
/// dropped when the STORED server-control stamp is strictly newer than
/// the incoming one (a newer server-control era has already been seen);
/// equal passes and re-stamps.</item>
/// </list>
///
/// <para>The compare itself is <c>CPhysicsObj::is_newer</c> (0x00451ad0);
/// the Binary Ninja pseudo-C mangles its setcc returns, so the port follows
/// ACE's verbatim <c>PhysicsObj.is_newer</c> (PhysicsObj.cs:2853-2859),
/// cross-checked against the decomp's branch structure.</para>
/// </summary>
public sealed class MotionSequenceGate
{
private ushort _instanceTs; // update_times[INSTANCE_TS = 8]
private ushort _movementTs; // update_times[MOVEMENT_TS = 1]
private ushort _serverControlTs; // update_times[SERVER_CONTROLLED_MOVE_TS = 5]
private bool _seeded;
/// <summary>
/// <c>CPhysicsObj::is_newer</c> (0x00451ad0): true when
/// <paramref name="newStamp"/> is newer than <paramref name="oldStamp"/>
/// under u16 wraparound — when the absolute difference exceeds 0x7fff
/// the numerically SMALLER value is the newer one (the counter wrapped).
/// </summary>
public static bool IsNewer(ushort oldStamp, ushort newStamp)
{
if (Math.Abs(newStamp - oldStamp) > short.MaxValue)
return newStamp < oldStamp;
return oldStamp < newStamp;
}
/// <summary>
/// Seed the stamps from the entity's CreateObject PhysicsDesc timestamp
/// block. Retail initializes <c>update_times</c> wholesale from the 9
/// u16s at the tail of PhysicsDesc (ACE
/// <c>WorldObject_Networking.cs:411-420</c> writes them in
/// PhysicsTimeStamp enum order); without this, an entity whose movement
/// sequence is already past 0x8000 at spawn would have every subsequent
/// movement event judged stale against a zero stamp.
///
/// <para>The first Seed adopts the stamps wholesale (fresh object);
/// subsequent Seeds only move stamps FORWARD. This makes the #138
/// rehydrate path (which replays a RETAINED CreateObject through the
/// spawn handler) a no-op instead of a stamp regression, while a genuine
/// wire re-create (server sequences only advance) still seeds correctly.
/// Retail has no equivalent replay path — its objects keep their stamps
/// for their whole lifetime — so advance-only is the faithful mapping.</para>
/// </summary>
public void Seed(ushort instanceSeq, ushort movementSeq, ushort serverControlSeq)
{
if (!_seeded)
{
_instanceTs = instanceSeq;
_movementTs = movementSeq;
_serverControlTs = serverControlSeq;
_seeded = true;
return;
}
if (IsNewer(_instanceTs, instanceSeq)) _instanceTs = instanceSeq;
if (IsNewer(_movementTs, movementSeq)) _movementTs = movementSeq;
if (IsNewer(_serverControlTs, serverControlSeq)) _serverControlTs = serverControlSeq;
}
/// <summary>
/// Apply the retail three-stamp gate to an inbound movement event.
/// Returns true when the event should be applied (stamps updated),
/// false when it must be dropped as stale/duplicate/superseded.
/// </summary>
public bool TryAcceptMovementEvent(ushort instanceSeq, ushort movementSeq, ushort serverControlSeq)
{
// Dispatch-level incarnation gate — runs before any other stamp is
// touched (retail drops at DispatchSmartBoxEvent, never reaching
// SetObjectMovement).
if (IsNewer(instanceSeq, _instanceTs))
return false;
_instanceTs = instanceSeq; // adopt equal-or-newer (AD-32; retail queues newer)
// Gate 1: movement sequence must be strictly newer.
if (!IsNewer(_movementTs, movementSeq))
return false;
_movementTs = movementSeq; // stamped BEFORE the server-control gate, per retail
// Gate 2: drop when a newer server-control era has already been seen.
if (IsNewer(serverControlSeq, _serverControlTs))
return false;
_serverControlTs = serverControlSeq;
return true;
}
}

View file

@ -19,18 +19,27 @@ namespace AcDream.Core.Physics;
// ────────────────────────────────────────────────────────────────────────────
/// <summary>
/// State flags stored at struct offset +0xA8 (PhysicsState).
/// Only the flags relevant to this simulation layer are included.
/// State flags stored at struct offset +0xA8 (<c>PhysicsState</c>), verbatim
/// from the retail <c>PhysicsState</c> enum in <c>acclient.h:2815</c>.
/// </summary>
[Flags]
public enum PhysicsStateFlags : uint
{
None = 0,
Static = 0x00000001, // bit 0 — never moves
Ethereal = 0x00000004, // bit 2 — no collision
ReportCollisions = 0x00000010,
Gravity = 0x00000400, // bit 10 — apply downward gravity
Hidden = 0x00001000,
None = 0x00000000,
Static = 0x00000001,
Ethereal = 0x00000004,
ReportCollisions = 0x00000008,
IgnoreCollisions = 0x00000010,
NoDraw = 0x00000020,
Missile = 0x00000040,
Pushable = 0x00000080,
AlignPath = 0x00000100,
PathClipped = 0x00000200,
Gravity = 0x00000400,
Lighting = 0x00000800,
ParticleEmitter = 0x00001000,
Hidden = 0x00004000,
ScriptedCollision = 0x00008000,
/// <summary>
/// A6.P7 (2026-05-25): retail HAS_PHYSICS_BSP_PS bit
/// (acclient.h:2833). When set, the entity exposes a per-Setup
@ -43,7 +52,7 @@ public enum PhysicsStateFlags : uint
/// state 0x10008 (STATIC | REPORT_COLLISIONS | HAS_PHYSICS_BSP).
/// ACE name: <c>PhysicsState.HasPhysicsBSP</c>.
/// </summary>
HasPhysicsBsp = 0x00010000, // bit 16 — retail HAS_PHYSICS_BSP_PS
HasPhysicsBsp = 0x00010000,
/// <summary>
/// L.3a (2026-04-30): retail INELASTIC_PS bit (acclient.h:2834).
/// When set, wall-collisions zero the velocity instead of reflecting.
@ -51,8 +60,14 @@ public enum PhysicsStateFlags : uint
/// impact rather than bounce. The player NEVER has this flag set —
/// player wall-hits use the reflection path with elasticity ~0.05.
/// </summary>
Inelastic = 0x00020000, // bit 17 — retail INELASTIC_PS
Sledding = 0x00800000, // bit 23 — sledding (modified friction)
Inelastic = 0x00020000,
HasDefaultAnim = 0x00040000,
HasDefaultScript = 0x00080000,
Cloaked = 0x00100000,
ReportAsEnvironment = 0x00200000,
EdgeSlide = 0x00400000,
Sledding = 0x00800000,
Frozen = 0x01000000,
}
/// <summary>

View file

@ -0,0 +1,216 @@
using System;
namespace AcDream.Core.Physics;
public enum CreateObjectTimestampDisposition
{
StaleGeneration,
InitialGeneration,
ExistingGeneration,
NewGeneration,
}
public enum PositionTimestampDisposition
{
Rejected,
Apply,
ForcePosition,
}
/// <summary>
/// Per-object retail <c>update_times[NUM_PHYSICS_TS]</c> gate. Channel order
/// is <c>PhysicsTimeStamp</c> from <c>acclient.h:6084</c>. The comparator and
/// channel mutation order are ported from <c>CPhysicsObj::is_newer</c>
/// 0x00451AD0, <c>CPhysicsObj::newer_event</c> 0x00451B10,
/// <c>SmartBox::DoSetState</c> 0x004520D0,
/// <c>SmartBox::DoVectorUpdate</c> 0x004521C0, and
/// <c>SmartBox::HandleReceivedPosition</c> 0x00453FD0.
/// </summary>
public sealed class PhysicsTimestampGate
{
private readonly ushort[] _timestamps = new ushort[9];
private bool _seeded;
private const int Position = 0;
private const int Movement = 1;
private const int State = 2;
private const int Vector = 3;
private const int Teleport = 4;
private const int ServerControlledMove = 5;
private const int ForcePosition = 6;
private const int ObjDesc = 7;
private const int Instance = 8;
public ushort PositionTimestamp => _timestamps[Position];
public ushort MovementTimestamp => _timestamps[Movement];
public ushort StateTimestamp => _timestamps[State];
public ushort VectorTimestamp => _timestamps[Vector];
public ushort TeleportTimestamp => _timestamps[Teleport];
public ushort ServerControlledMoveTimestamp => _timestamps[ServerControlledMove];
public ushort ForcePositionTimestamp => _timestamps[ForcePosition];
public ushort ObjDescTimestamp => _timestamps[ObjDesc];
public ushort InstanceTimestamp => _timestamps[Instance];
/// <summary>
/// Retail u16 wrap comparison. At the exact half-range ambiguity
/// (0x8000), the numerically lower stamp is considered newer.
/// </summary>
public static bool IsNewer(ushort oldStamp, ushort newStamp)
{
int distance = Math.Abs((int)newStamp - oldStamp);
return distance > 0x7FFF ? newStamp < oldStamp : oldStamp < newStamp;
}
/// <summary>
/// Begins CreateObject processing. A newer INSTANCE_TS starts a fresh
/// generation and replaces every channel wholesale. A same-generation
/// CreateObject leaves all channels untouched so its Position, Parent,
/// Pickup, Movement, State, Vector, and ObjDesc branches can pass through
/// the same per-channel methods used by their standalone packets.
/// </summary>
public CreateObjectTimestampDisposition SeedForCreateObject(
ushort position,
ushort movement,
ushort state,
ushort vector,
ushort teleport,
ushort serverControlledMove,
ushort forcePosition,
ushort objDesc,
ushort instance)
{
Span<ushort> incoming =
[position, movement, state, vector, teleport, serverControlledMove, forcePosition, objDesc, instance];
if (!_seeded)
{
incoming.CopyTo(_timestamps);
_seeded = true;
return CreateObjectTimestampDisposition.InitialGeneration;
}
ushort currentInstance = _timestamps[Instance];
if (IsNewer(currentInstance, instance))
{
incoming.CopyTo(_timestamps);
return CreateObjectTimestampDisposition.NewGeneration;
}
if (IsNewer(instance, currentInstance))
return CreateObjectTimestampDisposition.StaleGeneration;
return CreateObjectTimestampDisposition.ExistingGeneration;
}
public bool TryAcceptMovementEvent(
ushort instance,
ushort movement,
ushort serverControlledMove)
{
if (!TryAcceptInstance(instance)) return false;
if (!AdvanceStrict(Movement, movement)) return false;
// Retail consumes MOVEMENT_TS before checking the server-control
// era. Equal server-control stamps pass.
if (IsNewer(serverControlledMove, _timestamps[ServerControlledMove]))
return false;
_timestamps[ServerControlledMove] = serverControlledMove;
return true;
}
public bool TryAcceptStateEvent(ushort instance, ushort state) =>
TryAcceptInstance(instance) && AdvanceStrict(State, state);
public bool TryAcceptVectorEvent(ushort instance, ushort vector) =>
TryAcceptInstance(instance) && AdvanceStrict(Vector, vector);
public bool TryAcceptObjDescEvent(ushort instance, ushort objDesc) =>
TryAcceptInstance(instance) && AdvanceStrict(ObjDesc, objDesc);
/// <summary>
/// ParentEvent and PickupEvent share POSITION_TS with UpdatePosition.
/// Retail exact-gates INSTANCE_TS, then strictly advances POSITION_TS.
/// </summary>
public bool TryAcceptPositionChannelEvent(ushort instance, ushort position) =>
TryAcceptInstance(instance) && AdvanceStrict(Position, position);
public bool IsCurrentInstance(ushort instance) => TryAcceptInstance(instance);
/// <summary>
/// Standalone PlayerTeleport (F751) is admitted when it is equal to or
/// newer than the current TELEPORT_TS. It does not advance that channel;
/// the destination Position packet does.
/// </summary>
public bool IsFreshTeleportStart(ushort teleport) =>
_seeded && !IsNewer(teleport, _timestamps[Teleport]);
/// <summary>
/// Delete/Pickup events only affect the exact live incarnation. Retail
/// drops older deletes and queues newer-incarnation deletes without
/// touching the current object.
/// </summary>
public bool TryAcceptDeleteEvent(ushort instance, bool isLocalPlayer = false) =>
!isLocalPlayer && _seeded && instance == _timestamps[Instance];
/// <summary>
/// Ports the timestamp portion of <c>HandleReceivedPosition</c>. POSITION
/// is tentatively advanced, then restored when a newer TELEPORT stamp is
/// already known. A fresh teleport advances its own channel. The local
/// player's FORCE_POSITION channel is independent; non-player objects do
/// not consume it.
/// </summary>
public PositionTimestampDisposition TryAcceptPositionEvent(
ushort instance,
ushort position,
ushort teleport,
ushort forcePosition,
bool isLocalPlayer)
{
if (!TryAcceptInstance(instance)) return PositionTimestampDisposition.Rejected;
if (isLocalPlayer && IsNewer(_timestamps[ForcePosition], forcePosition))
{
_timestamps[ForcePosition] = forcePosition;
// SmartBox::HandleReceivedPosition 0x00453FD0: a fresh local
// FORCE_POSITION whose teleport is exactly equal blips immediately,
// assigns POSITION_TS directly (even equal/older), preserves the
// current heading, sends a position event, and returns WITHOUT
// advancing TELEPORT_TS.
if (teleport == _timestamps[Teleport])
{
_timestamps[Position] = position;
return PositionTimestampDisposition.ForcePosition;
}
}
ushort previousPosition = _timestamps[Position];
if (!AdvanceStrict(Position, position)) return PositionTimestampDisposition.Rejected;
if (IsNewer(teleport, _timestamps[Teleport]))
{
_timestamps[Position] = previousPosition;
return PositionTimestampDisposition.Rejected;
}
if (IsNewer(_timestamps[Teleport], teleport))
_timestamps[Teleport] = teleport;
return PositionTimestampDisposition.Apply;
}
private bool AdvanceStrict(int channel, ushort incoming)
{
if (!IsNewer(_timestamps[channel], incoming)) return false;
_timestamps[channel] = incoming;
return true;
}
private bool TryAcceptInstance(ushort incoming)
{
// Retail applies only the current incarnation: older packets drop and
// future-incarnation packets queue. Until LiveEntityRuntime owns that
// queue, acdream drops both mismatches (AD-32) so a future packet can
// never mutate or poison the current generation's channel stamps.
return _seeded && incoming == _timestamps[Instance];
}
}

View file

@ -0,0 +1,30 @@
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Retail <c>Position::IsValid</c> 0x005A9480 composed with
/// <c>Frame::IsValid</c> 0x00534ED0. The quaternion test is performed on its
/// squared norm with retail's exact <c>0.0002 * 5</c> tolerance.
/// </summary>
public static class PositionFrameValidation
{
public static bool IsValid(uint cellId, Vector3 origin, Quaternion rotation)
{
if (!LandDefs.InboundValidCellId(cellId)
|| float.IsNaN(origin.X)
|| float.IsNaN(origin.Y)
|| float.IsNaN(origin.Z)
|| float.IsNaN(rotation.W)
|| float.IsNaN(rotation.X)
|| float.IsNaN(rotation.Y)
|| float.IsNaN(rotation.Z))
{
return false;
}
float normSquared = rotation.LengthSquared();
return !float.IsNaN(normSquared)
&& MathF.Abs(normSquared - 1f) <= 0.001f;
}
}

View file

@ -21,6 +21,7 @@ public enum SelectionChangeReason
SelectedObjectRemoved,
CombatTargetDied,
PreviousSelection,
SessionReset,
}
public readonly record struct SelectionTransition(
@ -68,6 +69,30 @@ public sealed class SelectionState : ISelectionService
=> PreviousObjectId is uint previous
&& Set(previous, source, SelectionChangeReason.PreviousSelection);
/// <summary>
/// Ends a world session. Unlike an ordinary selection clear, no previous
/// object survives for the next session: server GUIDs may be reused by a
/// different logical incarnation after reconnect.
/// </summary>
public bool Reset(SelectionChangeSource source = SelectionChangeSource.System)
{
uint? old = SelectedObjectId;
SelectedObjectId = null;
PreviousObjectId = null;
PreviousValidObjectId = null;
if (old is null)
return false;
var transition = new SelectionTransition(
old,
null,
source,
SelectionChangeReason.SessionReset);
Changed?.Invoke(transition);
DispatchPluginChanged(new SelectionChangedEvent(old, null));
return true;
}
bool ISelectionService.Select(uint objectId)
=> Select(objectId, SelectionChangeSource.Plugin);