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

@ -0,0 +1,71 @@
using AcDream.App.UI;
using AcDream.Core.Items;
namespace AcDream.App.Tests.UI;
public sealed class AutoWieldGenerationTests
{
private const uint Player = 0x50000001u;
[Fact]
public void GenerationReplacementCancelsPendingWeaponSwitch()
{
var objects = new ClientObjectTable();
var blocker = new ClientObject
{
ObjectId = 0x60000001u,
WielderId = Player,
ValidLocations = EquipMask.MeleeWeapon,
};
objects.AddOrUpdate(blocker);
objects.MoveItem(
blocker.ObjectId,
Player,
newSlot: -1,
newEquipLocation: EquipMask.MeleeWeapon);
var requested = new ClientObject
{
ObjectId = 0x60000002u,
ContainerId = Player,
ValidLocations = EquipMask.MeleeWeapon,
};
objects.AddOrUpdate(requested);
using var controller = new AutoWieldController(
objects,
() => Player,
sendWield: null,
sendPutItemInContainer: (_, _, _) => { },
toast: null);
Assert.True(controller.TryWield(requested));
Assert.True(controller.IsBusy);
objects.ReplaceGeneration(WorldReplacement(blocker.ObjectId), generation: 2);
Assert.False(controller.IsBusy);
}
private static WeenieData WorldReplacement(uint guid) => new(
Guid: guid,
Name: "replacement",
Type: ItemType.Misc,
WeenieClassId: 1,
IconId: 0,
IconOverlayId: 0,
IconUnderlayId: 0,
Effects: 0,
Value: null,
StackSize: null,
StackSizeMax: null,
Burden: null,
ContainerId: null,
WielderId: null,
ValidLocations: null,
CurrentWieldedLocation: null,
Priority: null,
ItemsCapacity: null,
ContainersCapacity: null,
Structure: null,
MaxStructure: null,
Workmanship: null);
}

View file

@ -125,6 +125,40 @@ public class InventoryControllerTests
Assert.Equal(0u, containers.GetItem(1)!.ItemId); // padded empty frame
}
[Fact]
public void SessionTableClearRebuildsInventoryWithoutOldObjects()
{
var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedContained(objects, 0xAu, Player, slot: 0);
SeedBag(objects, 0xCu, slot: 1);
Bind(layout, objects);
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId);
Assert.Equal(0xCu, containers.GetItem(0)!.ItemId);
objects.Clear();
Assert.Equal(0u, grid.GetItem(0)!.ItemId);
Assert.Equal(0u, containers.GetItem(0)!.ItemId);
}
[Fact]
public void GenerationReplacementRemovesOldOwnedProjectionAndSelection()
{
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedContained(objects, 0xAu, Player, slot: 0);
var selection = new SelectionState();
selection.Select(0xAu, SelectionChangeSource.Inventory);
Bind(layout, objects, selection: selection);
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId);
objects.ReplaceGeneration(WorldReplacement(0xAu), generation: 2);
Assert.Equal(0u, grid.GetItem(0)!.ItemId);
Assert.Null(selection.SelectedObjectId);
}
[Fact]
public void Populate_uses_manifest_container_hint_before_create_object_details()
{
@ -557,6 +591,30 @@ public class InventoryControllerTests
private static ItemDragPayload Payload(uint obj) => new(obj, ItemDragSource.Inventory, 0, new UiItemSlot());
private static WeenieData WorldReplacement(uint guid) => new(
Guid: guid,
Name: "replacement",
Type: ItemType.Misc,
WeenieClassId: 1,
IconId: 0,
IconOverlayId: 0,
IconUnderlayId: 0,
Effects: 0,
Value: null,
StackSize: null,
StackSizeMax: null,
Burden: null,
ContainerId: null,
WielderId: null,
ValidLocations: null,
CurrentWieldedLocation: null,
Priority: null,
ItemsCapacity: null,
ContainersCapacity: null,
Structure: null,
MaxStructure: null,
Workmanship: null);
[Fact]
public void Drop_onOccupiedGridCell_insertsBefore_andMovesLocally()
{

View file

@ -64,6 +64,30 @@ public class PaperdollControllerTests
t.MoveItem(guid, Pack, newSlot: 0);
}
private static WeenieData WorldReplacement(uint guid) => new(
Guid: guid,
Name: "replacement",
Type: ItemType.Misc,
WeenieClassId: 1,
IconId: 0,
IconOverlayId: 0,
IconUnderlayId: 0,
Effects: 0,
Value: null,
StackSize: null,
StackSizeMax: null,
Burden: null,
ContainerId: null,
WielderId: null,
ValidLocations: null,
CurrentWieldedLocation: null,
Priority: null,
ItemsCapacity: null,
ContainersCapacity: null,
Structure: null,
MaxStructure: null,
Workmanship: null);
[Fact]
public void Populate_shows_equipped_item_in_its_slot()
{
@ -75,6 +99,41 @@ public class PaperdollControllerTests
Assert.Equal(0u, lists[ShieldSlot].Cell.ItemId); // shield slot empty
}
[Fact]
public void SessionTableClearRemovesOldEquipmentAndLocksAetheriaSlots()
{
var (layout, lists) = BuildLayout();
var objects = new ClientObjectTable();
SeedEquipped(objects, 0xA01u, EquipMask.HeadWear);
objects.AddOrUpdate(new ClientObject { ObjectId = Player });
objects.UpdateIntProperty(
Player,
AetheriaUnlocks.PropertyId,
(int)AetheriaUnlockState.Blue);
Bind(layout, objects);
Assert.Equal(0xA01u, lists[HeadSlot].Cell.ItemId);
Assert.True(lists[BlueAetheriaSlot].Visible);
objects.Clear();
Assert.Equal(0u, lists[HeadSlot].Cell.ItemId);
Assert.False(lists[BlueAetheriaSlot].Visible);
}
[Fact]
public void GenerationReplacementRemovesOldEquippedProjection()
{
var (layout, lists) = BuildLayout();
var objects = new ClientObjectTable();
SeedEquipped(objects, 0xA01u, EquipMask.HeadWear);
Bind(layout, objects);
Assert.Equal(0xA01u, lists[HeadSlot].Cell.ItemId);
objects.ReplaceGeneration(WorldReplacement(0xA01u), generation: 2);
Assert.Equal(0u, lists[HeadSlot].Cell.ItemId);
}
[Fact]
public void ClickEquippedItem_updatesSharedSelection()
{

View file

@ -115,6 +115,34 @@ public class ToolbarControllerTests
Assert.Equal(0u, slots[Row1[1]].Cell.ItemId); // others empty
}
[Fact]
public void SessionTableClearRemovesResolvedShortcutPresentation()
{
var (layout, slots, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject
{
ObjectId = 0x5001u,
WeenieClassId = 1u,
IconId = 0x06001234u,
});
var shortcuts = new List<ShortcutEntry>
{
new(Index: 0, ObjectId: 0x5001u, SpellId: 0),
};
ToolbarController.Bind(
layout,
repo,
() => shortcuts,
iconIds: (_, _, _, _, _) => 0x77u,
useItem: _ => { });
Assert.Equal(0x5001u, slots[Row1[0]].Cell.ItemId);
repo.Clear();
Assert.Equal(0u, slots[Row1[0]].Cell.ItemId);
}
[Fact]
public void DeferredRebind_whenItemArrivesLate()
{

View file

@ -0,0 +1,490 @@
using System.Numerics;
using AcDream.App.World;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
namespace AcDream.App.Tests.World;
public sealed class InboundPhysicsStateControllerTests
{
[Fact]
public void SameGenerationCreate_RoutesChannelsIndependently()
{
var controller = new InboundPhysicsStateController();
CreateObject.ServerPosition original = Position(0x0101FFFFu, 10f);
CreateObject.ServerPosition incoming = Position(0x0101FFFFu, 20f);
controller.AcceptCreate(Spawn(0x70000001u, 1, 10, 10, original, 0x408u));
InboundCreateResult refresh = controller.AcceptCreate(
Spawn(0x70000001u, 1, 9, 11, incoming, 0x448u));
Assert.Equal(CreateObjectTimestampDisposition.ExistingGeneration, refresh.Disposition);
Assert.NotNull(refresh.SameGenerationEvents);
SameGenerationCreateObjectEvents events = refresh.SameGenerationEvents.Value;
Assert.True(controller.TryApplyPosition(
events.Position!.Value,
isLocalPlayer: false,
forcePositionRotation: null,
currentLocalVelocity: null,
out PositionTimestampDisposition positionResult,
out _,
out _));
Assert.Equal(PositionTimestampDisposition.Rejected, positionResult);
Assert.True(controller.TryApplyState(events.State, out _));
Assert.True(controller.TryGetSnapshot(0x70000001u, out WorldSession.EntitySpawn accepted));
Assert.Equal(original, accepted.Position);
Assert.Equal(0x448u, accepted.PhysicsState);
}
[Fact]
public void PickupRetainsGeneration_AndFreshPositionReturnsToWorld()
{
var controller = new InboundPhysicsStateController();
controller.AcceptCreate(Spawn(
0x70000002u, 7, 20, 1, Position(0x0101FFFFu, 10f), 0x408u));
Assert.True(controller.TryApplyPickup(
new PickupEvent.Parsed(0x70000002u, 7, 21), out WorldSession.EntitySpawn pickedUp));
Assert.Null(pickedUp.Position);
var update = new WorldSession.EntityPositionUpdate(
0x70000002u,
Position(0x0101FFFFu, 30f),
null,
null,
true,
7,
22,
0,
0);
Assert.True(controller.TryApplyPosition(
update, false, null, null, out var disposition, out WorldSession.EntitySpawn returned, out _));
Assert.Equal(PositionTimestampDisposition.Apply, disposition);
Assert.Equal(30f, returned.Position!.Value.PositionX);
}
[Fact]
public void PositionPlacementAbsentAndPresentZeroBothApplyRetailZero()
{
var controller = new InboundPhysicsStateController();
WorldSession.EntitySpawn seed = Spawn(
0x70000009u, 7, 20, 1, Position(0x0101FFFFu, 10f), 0x408u);
seed = seed with
{
PlacementId = 7,
Physics = seed.Physics!.Value with { AnimationFrame = 7 },
};
controller.AcceptCreate(seed);
Assert.True(controller.TryApplyPosition(
new WorldSession.EntityPositionUpdate(
seed.Guid,
Position(0x0101FFFFu, 20f),
null,
null,
true,
7,
21,
0,
0),
false,
null,
null,
out PositionTimestampDisposition firstDisposition,
out WorldSession.EntitySpawn first,
out _));
Assert.Equal(PositionTimestampDisposition.Apply, firstDisposition);
Assert.Equal((uint)0, first.PlacementId);
Assert.Equal((uint)0, first.Physics!.Value.AnimationFrame);
Assert.True(controller.TryApplyPosition(
new WorldSession.EntityPositionUpdate(
seed.Guid,
Position(0x0101FFFFu, 30f),
null,
0,
true,
7,
22,
0,
0),
false,
null,
null,
out PositionTimestampDisposition secondDisposition,
out WorldSession.EntitySpawn second,
out _));
Assert.Equal(PositionTimestampDisposition.Apply, secondDisposition);
Assert.Equal((uint)0, second.PlacementId);
Assert.Equal((uint)0, second.Physics!.Value.AnimationFrame);
}
[Fact]
public void RemotePositionWithoutVelocityAppliesUnpackedZeroVector()
{
var controller = new InboundPhysicsStateController();
WorldSession.EntitySpawn seed = Spawn(
0x7000000Au, 7, 20, 1, Position(0x0101FFFFu, 10f), 0x408u);
seed = seed with
{
Physics = seed.Physics!.Value with { Velocity = new Vector3(4f, 5f, 6f) },
};
controller.AcceptCreate(seed);
Assert.True(controller.TryApplyPosition(
new WorldSession.EntityPositionUpdate(
seed.Guid,
Position(0x0101FFFFu, 20f),
null,
null,
true,
7,
21,
0,
0),
false,
null,
null,
out PositionTimestampDisposition disposition,
out WorldSession.EntitySpawn accepted,
out _));
Assert.Equal(PositionTimestampDisposition.Apply, disposition);
Assert.Equal(Vector3.Zero, accepted.Physics!.Value.Velocity);
}
[Fact]
public void LocalNormalCorrectionRetainsLiveVelocity_ButFreshTeleportZerosIt()
{
var controller = new InboundPhysicsStateController();
WorldSession.EntitySpawn seed = WithTimestamps(
Spawn(0x5000000Au, 7, 20, 1, Position(0x0101FFFFu, 10f), 0x408u),
teleport: 10);
controller.AcceptCreate(seed);
Vector3 liveVelocity = new(4f, 5f, 6f);
Assert.True(controller.TryApplyPosition(
new WorldSession.EntityPositionUpdate(
seed.Guid,
Position(0x0101FFFFu, 20f),
new Vector3(90f, 80f, 70f),
null,
true,
7,
21,
10,
0),
true,
null,
liveVelocity,
out PositionTimestampDisposition normalDisposition,
out WorldSession.EntitySpawn normal,
out _));
Assert.Equal(PositionTimestampDisposition.Apply, normalDisposition);
Assert.Equal(liveVelocity, normal.Physics!.Value.Velocity);
Assert.True(controller.TryApplyPosition(
new WorldSession.EntityPositionUpdate(
seed.Guid,
Position(0x0101FFFFu, 30f),
new Vector3(9f, 8f, 7f),
null,
true,
7,
22,
11,
0),
true,
null,
liveVelocity,
out PositionTimestampDisposition teleportDisposition,
out WorldSession.EntitySpawn teleported,
out _));
Assert.Equal(PositionTimestampDisposition.Apply, teleportDisposition);
Assert.Equal(Vector3.Zero, teleported.Physics!.Value.Velocity);
}
[Fact]
public void ParentEventWaitsForParent_ThenConsumesChildPositionOnce()
{
var controller = new InboundPhysicsStateController();
controller.AcceptCreate(Spawn(
0x70000003u, 3, 4, 1, Position(0x0101FFFFu, 10f), 0x408u));
var update = new ParentEvent.Parsed(
0x70000004u, 0x70000003u, 1, 2, 9, 5);
Assert.False(controller.TryApplyParent(update, out _));
controller.AcceptCreate(Spawn(
0x70000004u, 9, 1, 1, Position(0x0101FFFFu, 15f), 0x408u));
Assert.True(controller.TryApplyParent(update, out WorldSession.EntitySpawn attached));
Assert.Equal(0x70000004u, attached.ParentGuid);
Assert.Null(attached.Position);
Assert.False(controller.TryApplyParent(update, out _));
}
[Fact]
public void LocalDeleteIsRejected_AndClearDropsSessionState()
{
var controller = new InboundPhysicsStateController();
controller.AcceptCreate(Spawn(
0x50000001u, 4, 1, 1, Position(0x0101FFFFu, 10f), 0x408u));
Assert.False(controller.TryDelete(new DeleteObject.Parsed(0x50000001u, 4), true));
Assert.True(controller.TryGetSnapshot(0x50000001u, out _));
controller.Clear();
Assert.Empty(controller.Snapshots);
Assert.False(controller.IsFreshTeleportStart(0x50000001u, 1));
}
[Fact]
public void DeleteForUnknownGenerationIsRejected()
{
var controller = new InboundPhysicsStateController();
Assert.False(controller.TryDelete(
new DeleteObject.Parsed(0x5000FFFFu, 9), isLocalPlayer: false));
}
[Fact]
public void TeleportStartIsComparedWithoutAdvancingTeleportTimestamp()
{
var controller = new InboundPhysicsStateController();
WorldSession.EntitySpawn spawn = Spawn(
0x50000002u, 2, 1, 1, Position(0x0101FFFFu, 10f), 0x408u);
PhysicsSpawnData physics = spawn.Physics!.Value with
{
Timestamps = spawn.Physics.Value.Timestamps with { Teleport = 10 },
};
controller.AcceptCreate(spawn with { Physics = physics });
Assert.True(controller.IsFreshTeleportStart(0x50000002u, 10));
Assert.False(controller.IsFreshTeleportStart(0x50000002u, 9));
Assert.True(controller.IsFreshTeleportStart(0x50000002u, 11));
Assert.True(controller.IsFreshTeleportStart(0x50000002u, 11));
}
[Fact]
public void RejectedServerControlStillMirrorsConsumedMovementTimestamp()
{
var controller = new InboundPhysicsStateController();
WorldSession.EntitySpawn spawn = WithTimestamps(
Spawn(0x70000005u, 3, 1, 1, Position(0x0101FFFFu, 10f), 0x408u),
movement: 1,
serverControl: 5);
controller.AcceptCreate(spawn);
bool applied = controller.TryApplyMotion(
new WorldSession.EntityMotionUpdate(
spawn.Guid,
new CreateObject.ServerMotionState(0x3d, 0x11),
3,
2,
4,
false),
retainPayload: true,
out _,
out _);
Assert.False(applied);
Assert.True(controller.TryGetSnapshot(spawn.Guid, out WorldSession.EntitySpawn retained));
Assert.Equal((ushort)2, retained.MovementSequence);
Assert.Equal((ushort)5, retained.ServerControlSequence);
Assert.Equal((ushort)2, retained.Physics!.Value.Timestamps.Movement);
Assert.Equal((ushort)5, retained.Physics.Value.Timestamps.ServerControlledMove);
Assert.Equal(spawn.MotionState, retained.MotionState);
}
[Fact]
public void AutonomousLocalEchoRetainsPayloadButMirrorsAcceptedTimestamps()
{
var controller = new InboundPhysicsStateController();
WorldSession.EntitySpawn spawn = WithTimestamps(
Spawn(0x50000006u, 3, 1, 1, Position(0x0101FFFFu, 10f), 0x408u),
movement: 1,
serverControl: 5) with
{
MotionState = new CreateObject.ServerMotionState(0x3d, 0x10),
};
controller.AcceptCreate(spawn);
Assert.True(controller.TryApplyMotion(
new WorldSession.EntityMotionUpdate(
spawn.Guid,
new CreateObject.ServerMotionState(0x3d, 0x12),
3,
2,
5,
true),
retainPayload: false,
out WorldSession.EntitySpawn retained,
out _));
Assert.Equal(spawn.MotionState, retained.MotionState);
Assert.Equal((ushort)2, retained.MovementSequence);
Assert.Equal((ushort)5, retained.ServerControlSequence);
Assert.Equal((ushort)2, retained.Physics!.Value.Timestamps.Movement);
}
[Fact]
public void FreshForceWithOlderTeleportMirrorsForceButRejectsPose()
{
var controller = new InboundPhysicsStateController();
WorldSession.EntitySpawn spawn = WithTimestamps(
Spawn(0x50000007u, 3, 10, 1, Position(0x0101FFFFu, 10f), 0x408u),
teleport: 10,
forcePosition: 0);
controller.AcceptCreate(spawn);
Assert.True(controller.TryApplyPosition(
new WorldSession.EntityPositionUpdate(
spawn.Guid,
Position(0x0101FFFFu, 99f),
null,
null,
true,
3,
11,
9,
1),
isLocalPlayer: true,
forcePositionRotation: Quaternion.Identity,
currentLocalVelocity: Vector3.Zero,
out PositionTimestampDisposition disposition,
out WorldSession.EntitySpawn retained,
out _));
Assert.Equal(PositionTimestampDisposition.Rejected, disposition);
Assert.Equal(10f, retained.Position!.Value.PositionX);
Assert.Equal((ushort)10, retained.PositionSequence);
Assert.Equal((ushort)1, retained.Physics!.Value.Timestamps.ForcePosition);
Assert.Equal((ushort)10, retained.Physics.Value.Timestamps.Position);
}
[Fact]
public void ForcePositionStoresPreservedLocalHeadingForLaterHydration()
{
var controller = new InboundPhysicsStateController();
WorldSession.EntitySpawn spawn = WithTimestamps(
Spawn(0x50000008u, 3, 10, 1, Position(0x0101FFFFu, 10f), 0x408u),
teleport: 10,
forcePosition: 0);
controller.AcceptCreate(spawn);
Quaternion heading = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.75f);
Vector3 liveVelocity = new(1f, 2f, 3f);
Assert.True(controller.TryApplyPosition(
new WorldSession.EntityPositionUpdate(
spawn.Guid,
Position(0x0101FFFFu, 99f),
new Vector3(90f, 80f, 70f),
null,
true,
3,
9,
10,
1),
isLocalPlayer: true,
forcePositionRotation: heading,
currentLocalVelocity: liveVelocity,
out PositionTimestampDisposition disposition,
out WorldSession.EntitySpawn retained,
out _));
Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition);
Assert.Equal(99f, retained.Position!.Value.PositionX);
Assert.Equal(heading.W, retained.Position.Value.RotationW, precision: 6);
Assert.Equal(heading.X, retained.Position.Value.RotationX, precision: 6);
Assert.Equal(heading.Y, retained.Position.Value.RotationY, precision: 6);
Assert.Equal(heading.Z, retained.Position.Value.RotationZ, precision: 6);
Assert.Equal(retained.Position, retained.Physics!.Value.Position);
Assert.Equal(liveVelocity, retained.Physics.Value.Velocity);
}
private static WorldSession.EntitySpawn WithTimestamps(
WorldSession.EntitySpawn spawn,
ushort? movement = null,
ushort? serverControl = null,
ushort? teleport = null,
ushort? forcePosition = null)
{
PhysicsSpawnData physics = spawn.Physics!.Value;
PhysicsTimestamps timestamps = physics.Timestamps with
{
Movement = movement ?? physics.Timestamps.Movement,
ServerControlledMove = serverControl ?? physics.Timestamps.ServerControlledMove,
Teleport = teleport ?? physics.Timestamps.Teleport,
ForcePosition = forcePosition ?? physics.Timestamps.ForcePosition,
};
return spawn with
{
MovementSequence = timestamps.Movement,
ServerControlSequence = timestamps.ServerControlledMove,
Physics = physics with { Timestamps = timestamps },
};
}
private static WorldSession.EntitySpawn Spawn(
uint guid,
ushort instance,
ushort positionSequence,
ushort stateSequence,
CreateObject.ServerPosition? position,
uint state)
{
var timestamps = new PhysicsTimestamps(
positionSequence,
1,
stateSequence,
1,
0,
1,
0,
1,
instance);
var physics = new PhysicsSpawnData(
RawState: state,
Position: position,
Movement: null,
AnimationFrame: null,
SetupTableId: 0x02000001u,
MotionTableId: 0x09000001u,
SoundTableId: null,
PhysicsScriptTableId: null,
Parent: null,
Children: null,
Scale: null,
Friction: null,
Elasticity: null,
Translucency: null,
Velocity: null,
Acceleration: null,
AngularVelocity: null,
DefaultScriptType: null,
DefaultScriptIntensity: null,
Timestamps: timestamps);
return new WorldSession.EntitySpawn(
guid,
position,
0x02000001u,
Array.Empty<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
null,
null,
"fixture",
null,
null,
0x09000001u,
PhysicsState: state,
InstanceSequence: instance,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: positionSequence,
Physics: physics);
}
private static CreateObject.ServerPosition Position(uint cell, float x) =>
new(cell, x, 10f, 5f, 1f, 0f, 0f, 0f);
}

View file

@ -0,0 +1,335 @@
using AcDream.App.Rendering;
using AcDream.App.World;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Items;
namespace AcDream.App.Tests.World;
public sealed class ParentAttachmentStateTests
{
[Fact]
public void ParentEventBeforeChildCreateResolvesToCanonicalAttachment()
{
const uint parentGuid = 0x70000100u;
const uint childGuid = 0x70000101u;
var inbound = new InboundPhysicsStateController();
var relations = new ParentAttachmentState();
inbound.AcceptCreate(Spawn(parentGuid, instance: 9, positionSequence: 1));
relations.Enqueue(new ParentEvent.Parsed(parentGuid, childGuid, 1, 2, 9, 5));
Resolve(relations, inbound, childGuid);
Assert.False(relations.TryGetProjection(childGuid, out _));
inbound.AcceptCreate(Spawn(childGuid, instance: 3, positionSequence: 4));
Resolve(relations, inbound, childGuid);
Assert.True(relations.TryGetProjection(childGuid, out ParentAttachmentRelation projection));
Assert.Equal(parentGuid, projection.ParentGuid);
Assert.True(inbound.TryGetSnapshot(childGuid, out WorldSession.EntitySpawn child));
Assert.Null(child.Position);
Assert.Equal(parentGuid, child.ParentGuid);
Assert.Equal((ushort)5, child.PositionSequence);
}
[Fact]
public void MultipleQueuedRelationsRemainOrderedAndNewestAcceptedWins()
{
const uint parentGuid = 0x70000110u;
const uint childGuid = 0x70000111u;
var gate = new PhysicsTimestampGate();
gate.SeedForCreateObject(4, 0, 0, 0, 0, 0, 0, 0, 3);
var relations = new ParentAttachmentState();
relations.Enqueue(new ParentEvent.Parsed(parentGuid, childGuid, 1, 2, 9, 5));
relations.Enqueue(new ParentEvent.Parsed(parentGuid, childGuid, 1, 3, 9, 6));
relations.Resolve(
childGuid,
_ => true,
guid => guid == parentGuid ? (ushort)9 : null,
update => gate.TryAcceptPositionChannelEvent(3, update.ChildPositionSequence));
Assert.True(relations.TryGetProjection(childGuid, out ParentAttachmentRelation projection));
Assert.Equal((ushort)6, projection.ChildPositionSequence);
Assert.Equal((uint)3, projection.PlacementId);
relations.MarkProjected(childGuid);
Assert.True(relations.RestoreLastAccepted(childGuid));
Assert.True(relations.TryGetProjection(childGuid, out projection));
Assert.Equal((ushort)6, projection.ChildPositionSequence);
}
[Fact]
public void RejectedStaleRelationCannotReplaceAcceptedCreateRelation()
{
const uint childGuid = 0x70000121u;
var gate = new PhysicsTimestampGate();
gate.SeedForCreateObject(10, 0, 0, 0, 0, 0, 0, 0, 3);
var relations = new ParentAttachmentState();
relations.AcceptCreateObjectRelation(new ParentAttachmentRelation(
0x70000120u, childGuid, 1, 2, 0, 10));
relations.Enqueue(new ParentEvent.Parsed(
0x70000122u, childGuid, 1, 4, 8, 9));
relations.Resolve(
childGuid,
_ => true,
guid => guid == 0x70000122u ? (ushort)8 : null,
update => gate.TryAcceptPositionChannelEvent(3, update.ChildPositionSequence));
Assert.True(relations.TryGetProjection(childGuid, out ParentAttachmentRelation projection));
Assert.Equal(0x70000120u, projection.ParentGuid);
Assert.Equal((ushort)10, projection.ChildPositionSequence);
}
[Fact]
public void FutureParentGenerationDoesNotBlockCurrentRelationBehindIt()
{
const uint childGuid = 0x70000141u;
const uint parentGuid = 0x70000140u;
var gate = new PhysicsTimestampGate();
gate.SeedForCreateObject(4, 0, 0, 0, 0, 0, 0, 0, 3);
var relations = new ParentAttachmentState();
relations.Enqueue(new ParentEvent.Parsed(
parentGuid, childGuid, 1, 2, 10, 6));
relations.Enqueue(new ParentEvent.Parsed(
parentGuid, childGuid, 1, 3, 9, 5));
relations.Resolve(
childGuid,
_ => true,
guid => guid == parentGuid ? (ushort)9 : null,
update => gate.TryAcceptPositionChannelEvent(3, update.ChildPositionSequence));
Assert.True(relations.TryGetProjection(childGuid, out ParentAttachmentRelation projection));
Assert.Equal((ushort)5, projection.ChildPositionSequence);
Assert.Equal((uint)3, projection.PlacementId);
Assert.Contains(childGuid, relations.ChildrenWaitingForParent(parentGuid));
}
[Fact]
public void FutureRelationSurvivesAtomicParentGenerationReplacement()
{
const uint parentGuid = 0x70000150u;
const uint childGuid = 0x70000151u;
var inbound = new InboundPhysicsStateController();
var relations = new ParentAttachmentState();
var objects = new ClientObjectTable();
inbound.AcceptCreate(Spawn(parentGuid, instance: 9, positionSequence: 1));
inbound.AcceptCreate(Spawn(childGuid, instance: 3, positionSequence: 4));
relations.AcceptCreateObjectRelation(new ParentAttachmentRelation(
parentGuid, childGuid, 1, 2, 0, 4));
relations.Enqueue(new ParentEvent.Parsed(
parentGuid, childGuid, 1, 3, 10, 5));
Resolve(relations, inbound, childGuid);
objects.Ingest(MinimalWeenie(parentGuid, "generation 9"));
objects.ObjectRemovalClassified += removal =>
{
if (removal.Reason == ClientObjectRemovalReason.GenerationReplacement)
relations.EndGeneration(removal.Object.ObjectId, removal.Generation);
};
inbound.AcceptCreate(Spawn(parentGuid, instance: 10, positionSequence: 1));
objects.ReplaceGeneration(MinimalWeenie(parentGuid, "generation 10"), 10);
Resolve(relations, inbound, childGuid);
Assert.True(relations.TryGetProjection(childGuid, out ParentAttachmentRelation projection));
Assert.Equal(parentGuid, projection.ParentGuid);
Assert.Equal((ushort)10, projection.ParentInstanceSequence);
Assert.Equal((ushort)5, projection.ChildPositionSequence);
Assert.True(inbound.TryGetSnapshot(childGuid, out WorldSession.EntitySpawn child));
Assert.Equal(parentGuid, child.ParentGuid);
}
[Fact]
public void FutureParentRelationSurvivesChildGenerationReplacement()
{
const uint parentGuid = 0x70000180u;
const uint childGuid = 0x70000181u;
var relations = new ParentAttachmentState();
relations.Enqueue(new ParentEvent.Parsed(
parentGuid, childGuid, 1, 3, 10, 5));
// Parent generation 9 exists, so retail queues this blob against the
// future parent rather than against the already-live child.
relations.Resolve(
childGuid,
_ => true,
guid => guid == parentGuid ? (ushort)9 : null,
_ => throw new InvalidOperationException("future parent must not apply"));
relations.EndGeneration(childGuid, replacementGeneration: 4);
var replacementGate = new PhysicsTimestampGate();
replacementGate.SeedForCreateObject(4, 0, 0, 0, 0, 0, 0, 0, 4);
relations.Resolve(
childGuid,
_ => true,
guid => guid == parentGuid ? (ushort)10 : null,
update => replacementGate.TryAcceptPositionChannelEvent(
4, update.ChildPositionSequence));
Assert.True(relations.TryGetProjection(childGuid, out ParentAttachmentRelation projection));
Assert.Equal((ushort)10, projection.ParentInstanceSequence);
Assert.Equal((ushort)5, projection.ChildPositionSequence);
}
[Fact]
public void RemoveAndClearDiscardPendingAndRollbackState()
{
const uint parentGuid = 0x70000130u;
const uint childGuid = 0x70000131u;
var relations = new ParentAttachmentState();
relations.AcceptCreateObjectRelation(new ParentAttachmentRelation(
parentGuid, childGuid, 1, 2, 0, 4));
relations.Enqueue(new ParentEvent.Parsed(parentGuid, childGuid, 1, 3, 9, 5));
relations.RemoveObject(parentGuid);
Assert.False(relations.TryGetProjection(childGuid, out _));
Assert.False(relations.RestoreLastAccepted(childGuid));
Assert.Empty(relations.ChildrenWaitingForParent(parentGuid));
relations.AcceptCreateObjectRelation(new ParentAttachmentRelation(
parentGuid, childGuid, 1, 2, 0, 4));
relations.Clear();
Assert.False(relations.TryGetProjection(childGuid, out _));
Assert.False(relations.RestoreLastAccepted(childGuid));
}
[Fact]
public void UnparentProjectionRetainsFresherPendingParentEvent()
{
const uint parentGuid = 0x70000160u;
const uint childGuid = 0x70000161u;
var gate = new PhysicsTimestampGate();
gate.SeedForCreateObject(4, 0, 0, 0, 0, 0, 0, 0, 3);
var relations = new ParentAttachmentState();
relations.AcceptCreateObjectRelation(new ParentAttachmentRelation(
0x70000162u, childGuid, 1, 2, 0, 4));
relations.Enqueue(new ParentEvent.Parsed(
parentGuid, childGuid, 1, 3, 9, 5));
relations.EndChildProjection(childGuid);
Assert.False(relations.TryGetProjection(childGuid, out _));
relations.Resolve(
childGuid,
_ => true,
guid => guid == parentGuid ? (ushort)9 : null,
update => gate.TryAcceptPositionChannelEvent(3, update.ChildPositionSequence));
Assert.True(relations.TryGetProjection(childGuid, out ParentAttachmentRelation projection));
Assert.Equal(parentGuid, projection.ParentGuid);
Assert.Equal((ushort)5, projection.ChildPositionSequence);
}
[Fact]
public void ExactParentDeleteRetainsOnlyStrictlyFutureGenerationRelation()
{
const uint parentGuid = 0x70000170u;
const uint childGuid = 0x70000171u;
var gate = new PhysicsTimestampGate();
gate.SeedForCreateObject(4, 0, 0, 0, 0, 0, 0, 0, 3);
var relations = new ParentAttachmentState();
relations.Enqueue(new ParentEvent.Parsed(
parentGuid, childGuid, 1, 2, 9, 5));
relations.Enqueue(new ParentEvent.Parsed(
parentGuid, childGuid, 1, 3, 10, 6));
relations.DeleteGeneration(parentGuid, deletedGeneration: 9);
relations.Resolve(
childGuid,
_ => true,
guid => guid == parentGuid ? (ushort)10 : null,
update => gate.TryAcceptPositionChannelEvent(3, update.ChildPositionSequence));
Assert.True(relations.TryGetProjection(childGuid, out ParentAttachmentRelation projection));
Assert.Equal((ushort)10, projection.ParentInstanceSequence);
Assert.Equal((ushort)6, projection.ChildPositionSequence);
}
private static void Resolve(
ParentAttachmentState relations,
InboundPhysicsStateController inbound,
uint childGuid) =>
relations.Resolve(
childGuid,
guid => inbound.TryGetSnapshot(guid, out _),
guid => inbound.TryGetSnapshot(guid, out WorldSession.EntitySpawn spawn)
? spawn.InstanceSequence
: null,
update => inbound.TryApplyParent(update, out _));
private static WorldSession.EntitySpawn Spawn(
uint guid,
ushort instance,
ushort positionSequence)
{
var position = new CreateObject.ServerPosition(
0x0101FFFFu, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
var timestamps = new PhysicsTimestamps(
positionSequence, 1, 1, 1, 0, 1, 0, 1, instance);
var physics = new PhysicsSpawnData(
RawState: 0x408u,
Position: position,
Movement: null,
AnimationFrame: null,
SetupTableId: 0x02000001u,
MotionTableId: 0x09000001u,
SoundTableId: null,
PhysicsScriptTableId: null,
Parent: null,
Children: null,
Scale: null,
Friction: null,
Elasticity: null,
Translucency: null,
Velocity: null,
Acceleration: null,
AngularVelocity: null,
DefaultScriptType: null,
DefaultScriptIntensity: null,
Timestamps: timestamps);
return new WorldSession.EntitySpawn(
guid,
position,
0x02000001u,
Array.Empty<CreateObject.AnimPartChange>(),
Array.Empty<CreateObject.TextureChange>(),
Array.Empty<CreateObject.SubPaletteSwap>(),
null,
null,
"fixture",
null,
null,
0x09000001u,
PhysicsState: 0x408u,
InstanceSequence: instance,
MovementSequence: 1,
ServerControlSequence: 1,
PositionSequence: positionSequence,
Physics: physics);
}
private static WeenieData MinimalWeenie(uint guid, string name) => new(
Guid: guid,
Name: name,
Type: null,
WeenieClassId: 0,
IconId: 0,
IconOverlayId: 0,
IconUnderlayId: 0,
Effects: 0,
Value: null,
StackSize: null,
StackSizeMax: null,
Burden: null,
ContainerId: null,
WielderId: null,
ValidLocations: null,
CurrentWieldedLocation: null,
Priority: null,
ItemsCapacity: null,
ContainersCapacity: null,
Structure: null,
MaxStructure: null,
Workmanship: null);
}