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

@ -564,7 +564,7 @@ public sealed class CreateObjectTests
{
// L.2g S1 (DEV-6): index 1 of the 9-u16 PhysicsDesc timestamp block
// is ObjectMovement (ACE WorldObject_Networking.cs:412) — it seeds
// MotionSequenceGate's MOVEMENT_TS so post-spawn UpdateMotion events
// PhysicsTimestampGate's MOVEMENT_TS so post-spawn UpdateMotion events
// are judged against the entity's live sequence, not zero.
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000030u, name: "Runner", itemType: 0x10u,

View file

@ -37,41 +37,4 @@ public sealed class DeleteObjectTests
Assert.Equal((ushort)0x1234, parsed.Value.InstanceSequence);
}
/// <summary>
/// Regression guard: TryParse (0xF747 = true destroy) must always produce
/// FromPickup = false. PickupEvent (0xF74A) is a different opcode and is
/// the ONLY path that sets FromPickup = true (in WorldSession).
/// </summary>
[Fact]
public void TryParse_AlwaysReturnsFromPickupFalse()
{
Span<byte> body = stackalloc byte[12];
BinaryPrimitives.WriteUInt32LittleEndian(body, DeleteObject.Opcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.Slice(4), 0x80000001u);
BinaryPrimitives.WriteUInt16LittleEndian(body.Slice(8), 0x0001);
var parsed = DeleteObject.TryParse(body);
Assert.NotNull(parsed);
Assert.False(parsed!.Value.FromPickup,
"DeleteObject 0xF747 is a true destroy — FromPickup must be false.");
}
/// <summary>
/// FromPickup = true can be constructed via the record directly (as
/// WorldSession does for PickupEvent 0xF74A). Default is false.
/// </summary>
[Fact]
public void Parsed_DefaultFromPickupIsFalse()
{
var p = new DeleteObject.Parsed(0x80000001u, 1);
Assert.False(p.FromPickup);
}
[Fact]
public void Parsed_FromPickupTrueCanBeSet()
{
var p = new DeleteObject.Parsed(0x80000001u, 1, FromPickup: true);
Assert.True(p.FromPickup);
}
}

View file

@ -61,14 +61,16 @@ public sealed class ObjDescEventTests
// 4-byte align after AnimPartChanges (none here, so just align).
while (bytes.Count % 4 != 0) bytes.Add(0);
// Trailing instance + visual-desc sequences (consumed but ignored).
AppendU32(bytes, 0x12345678u);
AppendU32(bytes, 0x9ABCDEF0u);
// Trailing PhysicsTimestampPack: two u16 values.
AppendU16(bytes, 0x5678);
AppendU16(bytes, 0xDEF0);
var parsed = ObjDescEvent.TryParse(bytes.ToArray());
Assert.NotNull(parsed);
Assert.Equal(0x50000001u, parsed!.Value.Guid);
Assert.Equal((ushort)0x5678, parsed.Value.InstanceSequence);
Assert.Equal((ushort)0xDEF0, parsed.Value.ObjDescSequence);
var md = parsed.Value.ModelData;
Assert.Equal(0x0400007Eu, md.BasePaletteId);
@ -87,6 +89,9 @@ public sealed class ObjDescEventTests
Assert.Equal(3, md.TextureChanges[3].PartIndex);
Assert.Empty(md.AnimPartChanges);
Assert.Null(ObjDescEvent.TryParse(bytes.ToArray()[..^1]));
Assert.Null(ObjDescEvent.TryParse([.. bytes, 0]));
}
/// <summary>
@ -126,6 +131,13 @@ public sealed class ObjDescEventTests
dest.AddRange(tmp.ToArray());
}
private static void AppendU16(List<byte> dest, ushort value)
{
Span<byte> tmp = stackalloc byte[2];
BinaryPrimitives.WriteUInt16LittleEndian(tmp, value);
dest.AddRange(tmp.ToArray());
}
/// <summary>
/// Mirror of ACE's WritePackedDwordOfKnownType: strip the type prefix
/// if it matches <paramref name="knownType"/>, then write as a 16- or

View file

@ -214,6 +214,11 @@ public sealed class ProjectileVfxPacketFixtureTests
Assert.Equal(0xF754u, BinaryPrimitives.ReadUInt32LittleEndian(packet));
Assert.Equal(0x50000042u, BinaryPrimitives.ReadUInt32LittleEndian(packet[4..]));
Assert.Equal(0x33000099u, BinaryPrimitives.ReadUInt32LittleEndian(packet[8..]));
var parsed = PlayPhysicsScript.TryParse(packet);
Assert.Equal(new PlayPhysicsScript(0x50000042u, 0x33000099u), parsed);
Assert.Null(PlayPhysicsScript.TryParse(packet[..^1]));
Assert.Null(PlayPhysicsScript.TryParse([.. packet.ToArray(), 0]));
}
[Fact]
@ -227,6 +232,28 @@ public sealed class ProjectileVfxPacketFixtureTests
Assert.Equal(0xA5A5A5A5u, BinaryPrimitives.ReadUInt32LittleEndian(packet[8..]));
Assert.Equal(0x7FC01234u, BinaryPrimitives.ReadUInt32LittleEndian(packet[12..]));
Assert.True(float.IsNaN(BinaryPrimitives.ReadSingleLittleEndian(packet[12..])));
var parsed = PlayPhysicsScriptType.TryParse(packet);
Assert.NotNull(parsed);
Assert.Equal(0x50000042u, parsed.Value.Guid);
Assert.Equal(0xA5A5A5A5u, parsed.Value.RawScriptType);
Assert.Equal(0x7FC01234u, BitConverter.SingleToUInt32Bits(parsed.Value.Intensity));
Assert.Null(PlayPhysicsScriptType.TryParse(packet[..^1]));
Assert.Null(PlayPhysicsScriptType.TryParse([.. packet.ToArray(), 0]));
}
[Theory]
[InlineData(0x7F800000u)]
[InlineData(0xFF800000u)]
public void TypedF755_PreservesInfiniteIntensityBits(uint intensityBits)
{
byte[] packet = ProjectileVfxPacketFixtures.TypedF755UnknownNaN.ToArray();
BinaryPrimitives.WriteUInt32LittleEndian(packet.AsSpan(12), intensityBits);
var parsed = PlayPhysicsScriptType.TryParse(packet);
Assert.NotNull(parsed);
Assert.Equal(intensityBits, BitConverter.SingleToUInt32Bits(parsed.Value.Intensity));
}
[Fact]
@ -248,6 +275,25 @@ public sealed class ProjectileVfxPacketFixtureTests
Assert.Equal((ushort)0xFFF8, parsed.Value.PositionSequence);
Assert.Equal((ushort)0xFFF9, parsed.Value.MovementSequence);
Assert.Equal((ushort)0x0000, parsed.Value.InstanceSequence);
var physics = Assert.IsType<PhysicsSpawnData>(parsed.Value.Physics);
Assert.Equal(0x00000748u, physics.RawState);
Assert.True(physics.State.HasFlag(AcDream.Core.Physics.PhysicsStateFlags.Missile));
Assert.Equal(0x20000014u, physics.SoundTableId);
Assert.Equal(0x34000005u, physics.PhysicsScriptTableId);
Assert.Equal(new PhysicsAttachment(0x50000010u, 3u), physics.Parent);
Assert.Equal([new PhysicsAttachment(0x50000011u, 4u)],
physics.Children!.Value.ToArray());
Assert.Equal(0.25f, physics.Translucency);
Assert.Equal(new System.Numerics.Vector3(10f, 20f, 30f), physics.Velocity);
Assert.Equal(new System.Numerics.Vector3(1f, 2f, 3f), physics.Acceleration);
Assert.Equal(new System.Numerics.Vector3(0.1f, 0.2f, 0.3f), physics.AngularVelocity);
Assert.Equal(0xA5A5A5A5u, physics.DefaultScriptType);
Assert.Equal(0x7FC01234u,
BitConverter.SingleToUInt32Bits(physics.DefaultScriptIntensity!.Value));
Assert.Equal(new PhysicsTimestamps(
0xFFF8, 0xFFF9, 0xFFFA, 0xFFFB, 0xFFFC,
0xFFFD, 0xFFFE, 0xFFFF, 0x0000), physics.Timestamps);
}
[Fact]
@ -264,6 +310,10 @@ public sealed class ProjectileVfxPacketFixtureTests
Assert.Equal(0u, animation.Value.PlacementId);
Assert.Equal((ushort)0x1108, movement.Value.InstanceSequence);
Assert.Equal((ushort)0x1108, animation.Value.InstanceSequence);
Assert.NotNull(movement.Value.Physics!.Value.Movement);
Assert.True(movement.Value.Physics.Value.Movement!.Value.RawData.IsEmpty);
Assert.Null(movement.Value.Physics.Value.Movement!.Value.IsAutonomous);
Assert.Equal(0u, animation.Value.Physics!.Value.AnimationFrame);
}
[Fact]
@ -277,7 +327,24 @@ public sealed class ProjectileVfxPacketFixtureTests
Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(present[20..]));
Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(absent[12..]));
Assert.Equal(present.Length, absent.Length + sizeof(uint));
Assert.NotNull(CreateObject.TryParse(present));
Assert.NotNull(CreateObject.TryParse(absent));
var parsedPresent = CreateObject.TryParse(present);
var parsedAbsent = CreateObject.TryParse(absent);
Assert.NotNull(parsedPresent);
Assert.NotNull(parsedAbsent);
Assert.Equal(0u, parsedPresent.Value.Physics!.Value.PhysicsScriptTableId);
Assert.Null(parsedAbsent.Value.Physics!.Value.PhysicsScriptTableId);
}
[Fact]
public void GoldenCreateObject_RejectsEveryTruncationInsidePhysicsDesc()
{
byte[] packet = ProjectileVfxPacketFixtures.GoldenCreateObjectProjectilePhysicsFields;
// This fixture's PhysicsDesc ends at byte 168. Every shorter prefix
// cuts either a gated field or the mandatory nine-timestamp tail.
for (int length = 20; length < 168; length++)
Assert.Null(CreateObject.TryParse(packet.AsSpan(0, length)));
Assert.NotNull(CreateObject.TryParse(packet.AsSpan(0, 168)));
}
}

View file

@ -50,6 +50,10 @@ public class UpdatePositionTests
Assert.Equal(1f, result.Value.Position.RotationW);
Assert.Null(result.Value.Velocity);
Assert.Null(result.Value.PlacementId);
Assert.Equal((ushort)0x1001, result.Value.InstanceSequence);
Assert.Equal((ushort)0x2002, result.Value.PositionSequence);
Assert.Equal((ushort)0x3003, result.Value.TeleportSequence);
Assert.Equal((ushort)0x4004, result.Value.ForcePositionSequence);
}
[Fact]
@ -88,6 +92,7 @@ public class UpdatePositionTests
bw.Write(5f); bw.Write(6f); bw.Write(7f);
bw.Write(1f); bw.Write(0f); bw.Write(0f); bw.Write(0f);
bw.Write(100f); bw.Write(200f); bw.Write(300f); // velocity
WriteSequences(bw);
var result = UpdatePosition.TryParse(ms.ToArray());
Assert.NotNull(result);
@ -111,6 +116,7 @@ public class UpdatePositionTests
bw.Write(0f); bw.Write(0f); bw.Write(0f);
bw.Write(1f); bw.Write(0f); bw.Write(0f); bw.Write(0f);
bw.Write((uint)0x65); // Placement.Resting
WriteSequences(bw);
var result = UpdatePosition.TryParse(ms.ToArray());
Assert.NotNull(result);
@ -131,6 +137,7 @@ public class UpdatePositionTests
bw.Write(1f); bw.Write(0f); bw.Write(0f); bw.Write(0f);
bw.Write(1f); bw.Write(2f); bw.Write(3f);
bw.Write((uint)42u);
WriteSequences(bw);
var result = UpdatePosition.TryParse(ms.ToArray());
Assert.NotNull(result);
@ -153,6 +160,7 @@ public class UpdatePositionTests
bw.Write(cellId);
bw.Write(px); bw.Write(py); bw.Write(pz);
bw.Write(rw); bw.Write(rx); bw.Write(ry); bw.Write(rz);
WriteSequences(bw);
return ms.ToArray();
}
@ -169,6 +177,15 @@ public class UpdatePositionTests
bw.Write(cellId);
bw.Write(px); bw.Write(py); bw.Write(pz);
foreach (var c in rotationComponents) bw.Write(c);
WriteSequences(bw);
return ms.ToArray();
}
private static void WriteSequences(System.IO.BinaryWriter writer)
{
writer.Write((ushort)0x1001); // instance
writer.Write((ushort)0x2002); // position
writer.Write((ushort)0x3003); // teleport
writer.Write((ushort)0x4004); // force-position
}
}