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:
parent
d53fe30ffe
commit
8a5d77f7f4
50 changed files with 3809 additions and 649 deletions
|
|
@ -113,7 +113,7 @@ public sealed class WorldSession : IDisposable
|
|||
int? MaxStructure = null,
|
||||
float? Workmanship = null,
|
||||
// L.2g S1 (DEV-6): PhysicsDesc timestamp-block stamps that seed the
|
||||
// per-entity MotionSequenceGate (retail update_times INSTANCE_TS /
|
||||
// per-entity PhysicsTimestampGate (retail update_times INSTANCE_TS /
|
||||
// MOVEMENT_TS / SERVER_CONTROLLED_MOVE_TS).
|
||||
ushort InstanceSequence = 0,
|
||||
ushort MovementSequence = 0,
|
||||
|
|
@ -130,7 +130,8 @@ public sealed class WorldSession : IDisposable
|
|||
byte? CombatUse = null,
|
||||
string? PluralName = null,
|
||||
uint? PetOwnerId = null,
|
||||
ushort? AmmoType = null);
|
||||
ushort? AmmoType = null,
|
||||
PhysicsSpawnData? Physics = null);
|
||||
|
||||
/// <summary>
|
||||
/// Projects the wire-level CreateObject result into the stable session
|
||||
|
|
@ -189,7 +190,8 @@ public sealed class WorldSession : IDisposable
|
|||
CombatUse: parsed.CombatUse,
|
||||
PluralName: parsed.PluralName,
|
||||
PetOwnerId: parsed.PetOwnerId,
|
||||
AmmoType: parsed.AmmoType);
|
||||
AmmoType: parsed.AmmoType,
|
||||
Physics: parsed.Physics);
|
||||
|
||||
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
|
||||
public event Action<EntitySpawn>? EntitySpawned;
|
||||
|
|
@ -204,6 +206,13 @@ public sealed class WorldSession : IDisposable
|
|||
/// </summary>
|
||||
public event Action<DeleteObject.Parsed>? EntityDeleted;
|
||||
|
||||
/// <summary>
|
||||
/// Fires for retail PickupEvent (0xF74A). Pickup advances the object's
|
||||
/// shared POSITION_TS and removes only its world projection; it is not a
|
||||
/// DeleteObject and does not destroy the timestamp owner or weenie.
|
||||
/// </summary>
|
||||
public event Action<PickupEvent.Parsed>? EntityPickedUp;
|
||||
|
||||
/// <summary>
|
||||
/// Payload for <see cref="MotionUpdated"/>: the server guid of the entity
|
||||
/// whose motion changed and its new server-side stance + forward command.
|
||||
|
|
@ -235,7 +244,12 @@ public sealed class WorldSession : IDisposable
|
|||
uint Guid,
|
||||
CreateObject.ServerPosition Position,
|
||||
System.Numerics.Vector3? Velocity,
|
||||
bool IsGrounded);
|
||||
uint? PlacementId,
|
||||
bool IsGrounded,
|
||||
ushort InstanceSequence,
|
||||
ushort PositionSequence,
|
||||
ushort TeleportSequence,
|
||||
ushort ForcePositionSequence);
|
||||
|
||||
/// <summary>
|
||||
/// Fires when the session parses a 0xF748 UpdatePosition game message.
|
||||
|
|
@ -433,7 +447,10 @@ public sealed class WorldSession : IDisposable
|
|||
/// runtime). See <c>docs/research/2026-04-23-lightning-real.md</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public event Action<uint /*guid*/, uint /*scriptId*/>? PlayScriptReceived;
|
||||
public event Action<PlayPhysicsScript>? PlayPhysicsScriptReceived;
|
||||
|
||||
/// <summary>Fires for retail typed PhysicsScript playback (0xF755).</summary>
|
||||
public event Action<PlayPhysicsScriptType>? PlayPhysicsScriptTypeReceived;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5d — retail's <c>AdminEnvirons</c> packet (opcode
|
||||
|
|
@ -453,7 +470,7 @@ public sealed class WorldSession : IDisposable
|
|||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <c>0x76..0x7B</c> — Thunder1..Thunder6 sounds. Paired with
|
||||
/// a separate <see cref="PlayScriptReceived"/> from the server
|
||||
/// a separate <see cref="PlayPhysicsScriptReceived"/> from the server
|
||||
/// carrying the lightning-flash PhysicsScript.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
|
|
@ -505,6 +522,26 @@ public sealed class WorldSession : IDisposable
|
|||
public ushort ServerControlSequence => _serverControlSequence;
|
||||
public ushort TeleportSequence => _teleportSequence;
|
||||
public ushort ForcePositionSequence => _forcePositionSequence;
|
||||
|
||||
/// <summary>
|
||||
/// Publishes the local player's canonical, freshness-accepted physics
|
||||
/// timestamps for subsequent outbound movement messages. The App runtime
|
||||
/// calls this only after <c>PhysicsTimestampGate</c> commits the matching
|
||||
/// CreateObject/Movement/Position event; parsing alone never changes
|
||||
/// outbound authority.
|
||||
/// </summary>
|
||||
public void PublishAcceptedLocalPhysicsTimestamps(
|
||||
ushort instance,
|
||||
ushort serverControlledMove,
|
||||
ushort teleport,
|
||||
ushort forcePosition)
|
||||
{
|
||||
_instanceSequence = instance;
|
||||
_serverControlSequence = serverControlledMove;
|
||||
_teleportSequence = teleport;
|
||||
_forcePositionSequence = forcePosition;
|
||||
}
|
||||
|
||||
public CharacterList.Parsed? Characters { get; private set; }
|
||||
|
||||
private readonly NetClient _net;
|
||||
|
|
@ -536,7 +573,7 @@ public sealed class WorldSession : IDisposable
|
|||
// Movement sequence counters — echoed back in every MoveToState and
|
||||
// AutonomousPosition so the server can detect stale/reordered packets.
|
||||
// Initialized from CreateObject PhysicsData timestamps, updated by
|
||||
// UpdatePosition/UpdateMotion/PlayerTeleport. Per holtburger:
|
||||
// accepted UpdatePosition/UpdateMotion packets. Per holtburger:
|
||||
// instance=slot 8, teleport=slot 4, serverControl=slot 5, forcePosition=slot 6.
|
||||
private ushort _instanceSequence;
|
||||
private ushort _serverControlSequence;
|
||||
|
|
@ -878,15 +915,6 @@ public sealed class WorldSession : IDisposable
|
|||
var parsed = CreateObject.TryParse(body);
|
||||
if (parsed is not null)
|
||||
{
|
||||
// Initialize sequence counters from the player's own CreateObject.
|
||||
if (parsed.Value.Guid == Characters?.Characters.FirstOrDefault().Id)
|
||||
{
|
||||
_instanceSequence = parsed.Value.InstanceSequence;
|
||||
_teleportSequence = parsed.Value.TeleportSequence;
|
||||
_serverControlSequence = parsed.Value.ServerControlSequence;
|
||||
_forcePositionSequence = parsed.Value.ForcePositionSequence;
|
||||
}
|
||||
|
||||
EntitySpawned?.Invoke(ToEntitySpawn(parsed.Value));
|
||||
}
|
||||
}
|
||||
|
|
@ -898,18 +926,11 @@ public sealed class WorldSession : IDisposable
|
|||
}
|
||||
else if (op == PickupEvent.Opcode)
|
||||
{
|
||||
// ACE sends PickupEvent (0xF74A) when an object leaves the 3-D world view
|
||||
// (player picks it up, or a player unwields gear that goes back to their pack).
|
||||
// The WEENIE is NOT destroyed — it is moving into a container. We adapt to
|
||||
// DeleteObject.Parsed so the render/entity layer (GameWindow.OnLiveEntityDeleted)
|
||||
// still removes the 3-D WorldEntity, but FromPickup = true tells ObjectTableWiring
|
||||
// to skip the ClientObjectTable eviction (retail two-table model: PickupEvent
|
||||
// targets object_table only; DeleteObject 0xF747 targets weenie_object_table).
|
||||
// Pickup has its own POSITION_TS gate and retains the logical
|
||||
// object; do not collapse it into DeleteObject.
|
||||
var parsed = PickupEvent.TryParse(body);
|
||||
if (parsed is not null)
|
||||
EntityDeleted?.Invoke(
|
||||
new DeleteObject.Parsed(
|
||||
parsed.Value.Guid, parsed.Value.InstanceSequence, FromPickup: true));
|
||||
EntityPickedUp?.Invoke(parsed.Value);
|
||||
}
|
||||
else if (op == ParentEvent.Opcode)
|
||||
{
|
||||
|
|
@ -947,19 +968,16 @@ public sealed class WorldSession : IDisposable
|
|||
var posUpdate = UpdatePosition.TryParse(body);
|
||||
if (posUpdate is not null)
|
||||
{
|
||||
// Update sequence counters from the player's own position updates.
|
||||
if (posUpdate.Value.Guid == Characters?.Characters.FirstOrDefault().Id)
|
||||
{
|
||||
_instanceSequence = posUpdate.Value.InstanceSequence;
|
||||
_teleportSequence = posUpdate.Value.TeleportSequence;
|
||||
_forcePositionSequence = posUpdate.Value.ForcePositionSequence;
|
||||
}
|
||||
|
||||
PositionUpdated?.Invoke(new EntityPositionUpdate(
|
||||
posUpdate.Value.Guid,
|
||||
posUpdate.Value.Position,
|
||||
posUpdate.Value.Velocity,
|
||||
posUpdate.Value.IsGrounded));
|
||||
posUpdate.Value.PlacementId,
|
||||
posUpdate.Value.IsGrounded,
|
||||
posUpdate.Value.InstanceSequence,
|
||||
posUpdate.Value.PositionSequence,
|
||||
posUpdate.Value.TeleportSequence,
|
||||
posUpdate.Value.ForcePositionSequence));
|
||||
}
|
||||
}
|
||||
else if (op == VectorUpdate.Opcode)
|
||||
|
|
@ -1130,21 +1148,17 @@ public sealed class WorldSession : IDisposable
|
|||
EnvironChanged?.Invoke(envType);
|
||||
}
|
||||
}
|
||||
else if (op == 0xF754u) // PlayScript — server triggers a PhysicsScript on a target guid
|
||||
else if (op == PlayPhysicsScript.Opcode)
|
||||
{
|
||||
// Phase 6b: wire format `[u32 opcode][u32 guid][u32 scriptId]`
|
||||
// per chunk_006A0000.c:12320 disassembly. Dispatch the
|
||||
// event; GameWindow subscribes and feeds its
|
||||
// PhysicsScriptRunner. This is the channel retail uses for
|
||||
// lightning flashes, spell casts, emotes, combat FX, etc.
|
||||
if (body.Length >= 12)
|
||||
{
|
||||
uint targetGuid = System.Buffers.Binary.BinaryPrimitives
|
||||
.ReadUInt32LittleEndian(body.AsSpan(4, 4));
|
||||
uint scriptId = System.Buffers.Binary.BinaryPrimitives
|
||||
.ReadUInt32LittleEndian(body.AsSpan(8, 4));
|
||||
PlayScriptReceived?.Invoke(targetGuid, scriptId);
|
||||
}
|
||||
var script = PlayPhysicsScript.TryParse(body);
|
||||
if (script is not null)
|
||||
PlayPhysicsScriptReceived?.Invoke(script.Value);
|
||||
}
|
||||
else if (op == PlayPhysicsScriptType.Opcode)
|
||||
{
|
||||
var script = PlayPhysicsScriptType.TryParse(body);
|
||||
if (script is not null)
|
||||
PlayPhysicsScriptTypeReceived?.Invoke(script.Value);
|
||||
}
|
||||
else if (op == 0xF751u) // PlayerTeleport — server is moving us through a portal
|
||||
{
|
||||
|
|
@ -1157,12 +1171,12 @@ public sealed class WorldSession : IDisposable
|
|||
// movement; the LoginComplete is sent from GameWindow once
|
||||
// the destination UpdatePosition is received and the player
|
||||
// has been snapped to the new cell.
|
||||
ushort sequence = 0;
|
||||
if (body.Length >= 6)
|
||||
sequence = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(
|
||||
{
|
||||
ushort sequence = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(
|
||||
body.AsSpan(4, 2));
|
||||
_teleportSequence = sequence; // track for outbound movement messages
|
||||
TeleportStarted?.Invoke(sequence);
|
||||
TeleportStarted?.Invoke(sequence);
|
||||
}
|
||||
}
|
||||
else if (op == ObjDescEvent.Opcode)
|
||||
{
|
||||
|
|
@ -1172,7 +1186,7 @@ public sealed class WorldSession : IDisposable
|
|||
// RecipeManager.cs:403, GameActionSetSingleCharacterOption.cs:27).
|
||||
// Retail handler: SmartBox::HandleObjDescEvent (named-retail
|
||||
// 0x453340). Body layout: u32 opcode | u32 guid | ModelData |
|
||||
// u32 instanceSeq | u32 visualDescSeq.
|
||||
// u16 instanceSeq | u16 visualDescSeq.
|
||||
var parsed = ObjDescEvent.TryParse(body);
|
||||
if (parsed is not null)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue