acdream/src/AcDream.Core/Physics/PhysicsTimestampGate.cs
Erik 8a5d77f7f4 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>
2026-07-14 00:22:17 +02:00

216 lines
8.1 KiB
C#

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];
}
}