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
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,8 @@ namespace AcDream.Core.Net.Tests;
|
|||
/// ctor opens a UDP socket), so the subscription wiring is tested via the guard
|
||||
/// logic extracted as a local handler — this is the same lambda body that
|
||||
/// ObjectTableWiring.Wire wires to session.EntityDeleted. The retail two-table
|
||||
/// semantics are: PickupEvent (0xF74A) removes from the 3-D object_table but
|
||||
/// KEEPS the weenie in ClientObjectTable; DeleteObject (0xF747) evicts the
|
||||
/// weenie from both. FromPickup = true guards the eviction.
|
||||
/// semantics are: PickupEvent (0xF74A) is routed separately and keeps the
|
||||
/// weenie; DeleteObject (0xF747) evicts it from both projections.
|
||||
/// </summary>
|
||||
public sealed class ObjectTableWiringTests
|
||||
{
|
||||
|
|
@ -148,13 +147,6 @@ public sealed class ObjectTableWiringTests
|
|||
Assert.Equal(1, playerChanges);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The handler that ObjectTableWiring.Wire subscribes to session.EntityDeleted.
|
||||
// Testing it directly avoids needing a live WorldSession (UDP socket).
|
||||
// -------------------------------------------------------------------------
|
||||
private static Action<DeleteObject.Parsed> MakeEntityDeletedHandler(ClientObjectTable table)
|
||||
=> d => { if (!d.FromPickup) table.Remove(d.Guid); };
|
||||
|
||||
private static WeenieData MinimalWeenie(uint guid) => new(
|
||||
Guid: guid,
|
||||
Name: "Test Item",
|
||||
|
|
@ -180,47 +172,90 @@ public sealed class ObjectTableWiringTests
|
|||
Workmanship: null);
|
||||
|
||||
/// <summary>
|
||||
/// PickupEvent path: FromPickup = true → weenie is RETAINED in ClientObjectTable.
|
||||
/// The 3-D render entity is removed (EntityDeleted still fires), but the weenie
|
||||
/// data persists so the follow-up InventoryPutObjInContainer can find it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EntityDeleted_FromPickupTrue_RetainsWeenieInTable()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
var handler = MakeEntityDeletedHandler(table);
|
||||
|
||||
const uint guid = 0x80000100u;
|
||||
table.Ingest(MinimalWeenie(guid));
|
||||
Assert.NotNull(table.Get(guid)); // pre-condition: item is in the table
|
||||
|
||||
// Fire EntityDeleted as WorldSession does for PickupEvent (0xF74A).
|
||||
// The handler itself represents the EntityDeleted subscription — firing it IS the event.
|
||||
handler(new DeleteObject.Parsed(guid, 0, FromPickup: true));
|
||||
|
||||
// Post-condition: weenie still in table (it moved into a container, was NOT destroyed).
|
||||
// EntityDeleted still fires (the handler ran), so the 3-D render layer would remove the WorldEntity.
|
||||
Assert.NotNull(table.Get(guid));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DeleteObject path: FromPickup = false → weenie IS evicted from ClientObjectTable.
|
||||
/// DeleteObject is a true destroy and evicts the weenie from ClientObjectTable.
|
||||
/// Regression guard — true destroys (0xF747) must still clean up the table.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EntityDeleted_FromPickupFalse_EvictsWeenieFromTable()
|
||||
public void EntityDeleted_EvictsWeenieFromTable()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
var handler = MakeEntityDeletedHandler(table);
|
||||
|
||||
const uint guid = 0x80000200u;
|
||||
table.Ingest(MinimalWeenie(guid));
|
||||
Assert.NotNull(table.Get(guid)); // pre-condition: item is in the table
|
||||
|
||||
// Fire EntityDeleted as WorldSession does for DeleteObject (0xF747).
|
||||
handler(new DeleteObject.Parsed(guid, 0, FromPickup: false));
|
||||
ObjectTableWiring.ApplyEntityDelete(
|
||||
table, new DeleteObject.Parsed(guid, 0));
|
||||
|
||||
// Post-condition: weenie evicted (truly destroyed).
|
||||
Assert.Null(table.Get(guid));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewGenerationCreateObject_ReplacesPriorQualities()
|
||||
{
|
||||
const uint guid = 0x80000300u;
|
||||
var table = new ClientObjectTable();
|
||||
ClientObject old = table.Ingest(MinimalWeenie(guid));
|
||||
old.Properties.Ints[123u] = 456;
|
||||
|
||||
var stale = new WorldSession.EntitySpawn(
|
||||
Guid: guid,
|
||||
Position: null,
|
||||
SetupTableId: null,
|
||||
AnimPartChanges: [],
|
||||
TextureChanges: [],
|
||||
SubPalettes: [],
|
||||
BasePaletteId: null,
|
||||
ObjScale: null,
|
||||
Name: "Stale Name",
|
||||
ItemType: null,
|
||||
MotionState: null,
|
||||
MotionTableId: null);
|
||||
|
||||
int removed = 0;
|
||||
int generationRemoved = 0;
|
||||
table.ObjectRemoved += _ => removed++;
|
||||
table.ObjectRemovalClassified += removal =>
|
||||
{
|
||||
if (removal.Reason == ClientObjectRemovalReason.GenerationReplacement)
|
||||
generationRemoved++;
|
||||
};
|
||||
|
||||
ObjectTableWiring.ApplyEntitySpawn(table, stale, replaceGeneration: true);
|
||||
|
||||
Assert.Equal("Stale Name", table.Get(guid)!.Name);
|
||||
Assert.False(table.Get(guid)!.Properties.Ints.ContainsKey(123u));
|
||||
Assert.Equal(1, removed);
|
||||
Assert.Equal(1, generationRemoved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameGenerationCreateObject_MergesPriorQualities()
|
||||
{
|
||||
const uint guid = 0x80000400u;
|
||||
var table = new ClientObjectTable();
|
||||
ClientObject old = table.Ingest(MinimalWeenie(guid));
|
||||
old.Properties.Ints[123u] = 456;
|
||||
|
||||
var refresh = new WorldSession.EntitySpawn(
|
||||
Guid: guid,
|
||||
Position: null,
|
||||
SetupTableId: null,
|
||||
AnimPartChanges: [],
|
||||
TextureChanges: [],
|
||||
SubPalettes: [],
|
||||
BasePaletteId: null,
|
||||
ObjScale: null,
|
||||
Name: "Refreshed Name",
|
||||
ItemType: null,
|
||||
MotionState: null,
|
||||
MotionTableId: null);
|
||||
|
||||
ObjectTableWiring.ApplyEntitySpawn(table, refresh);
|
||||
|
||||
Assert.NotNull(table.Get(guid));
|
||||
Assert.Equal("Refreshed Name", table.Get(guid)!.Name);
|
||||
Assert.Equal(456, table.Get(guid)!.Properties.Ints[123u]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue