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

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

View file

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

View file

@ -192,6 +192,53 @@ public class PlayerMovementControllerTests
Assert.Equal(snapped, result.RenderPosition);
}
[Fact]
public void BlipPosition_ResnapsPoseWithoutStoppingActiveMotion()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Yaw = 0f;
controller.Update(PhysicsBody.MinQuantum, new MovementInput(Forward: true));
Vector3 velocity = controller.BodyVelocity;
Assert.True(velocity.LengthSquared() > 0f);
var corrected = new Vector3(100f, 98f, 50f);
controller.BlipPosition(corrected, 0x0001, corrected);
Assert.Equal(corrected, controller.Position);
Assert.Equal(corrected, controller.RenderPosition);
Assert.Equal(velocity, controller.BodyVelocity);
}
[Fact]
public void BlipPosition_PublishesCanonicalOutdoorCellAndLocalFrame()
{
var controller = new PlayerMovementController(MakeFlatEngine());
var world = new Vector3(150f, 193f, 50f);
var wireLocal = new Vector3(150f, 193f, 50f);
controller.BlipPosition(world, 0xA9B30038u, wireLocal);
Assert.Equal(0xA9B40031u, controller.CellId);
Assert.Equal(controller.CellId, controller.CellPosition.ObjCellId);
Assert.Equal(new Vector3(150f, 1f, 50f), controller.CellPosition.Frame.Origin);
Assert.Equal(world, controller.Position);
}
[Fact]
public void TeleportPosition_PublishesCanonicalOutdoorCellAndLocalFrame()
{
var controller = new PlayerMovementController(MakeFlatEngine());
var world = new Vector3(12f, 12f, 50f);
var wireLocal = new Vector3(12f, 12f, 50f);
controller.SetPosition(world, 0xA9B40031u, wireLocal);
Assert.Equal(0xA9B40001u, controller.CellId);
Assert.Equal(controller.CellId, controller.CellPosition.ObjCellId);
Assert.Equal(wireLocal, controller.CellPosition.Frame.Origin);
}
[Fact]
public void Update_HugeQuantumDiscard_ResnapsRenderInterpolationEndpoints()
{
@ -293,6 +340,22 @@ public class PlayerMovementControllerTests
Assert.True(controller.VerticalVelocity > 0f);
}
[Fact]
public void PositionEventGateRequiresContactAndWalkable()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
Assert.True(controller.CanSendPositionEvent);
controller.Update(1.0f, new MovementInput(Jump: true));
controller.Update(0.016f, new MovementInput(Jump: false));
Assert.True(controller.IsAirborne);
Assert.False(controller.CanSendPositionEvent);
}
[Fact]
public void JumpChargeSnapshot_TracksHeldChargeAndResetsOnRelease()
{

View file

@ -58,7 +58,7 @@ public class DoorBugTrajectoryReplayTests
// pos=(132.56,17.11,94.10)
private const uint DoorEntityId = 0x000F4246u;
private const uint DoorGfxObjId = 0x010044B5u;
private const uint DoorClosedState = 0x00010008u; // PERSISTENT_PS | 0x8 (no ETHEREAL)
private const uint DoorClosedState = 0x00010008u; // HAS_PHYSICS_BSP | REPORT_COLLISIONS
private const uint DoorLandblockId = 0xA9B40000u;
private static readonly Vector3 BspWorldPos = new(132.57f, 16.99f, 95.36f);

View file

@ -373,7 +373,7 @@ public class DoorCollisionApparatusTests
// landblock 0xA9B40000.
Vector3 doorWorldPos = new(12f, 12f, 0f);
Quaternion doorWorldRot = Quaternion.Identity;
const uint doorState = 0x10008u; // PhysicsState.HasPhysicsBSP | HasDefaultScript (typical)
const uint doorState = 0x10008u; // PhysicsState.HasPhysicsBSP | ReportCollisions
engine.ShadowObjects.RegisterMultiPart(
entityId: DoorEntityId,

View file

@ -4,8 +4,8 @@ using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Covers <see cref="MotionSequenceGate"/> — the inbound movement-event
/// staleness gate ported from retail (L.2g S1, deviation DEV-6):
/// Covers <see cref="PhysicsTimestampGate"/> — the inbound physics-event
/// staleness gate ported from retail:
///
/// <list type="bullet">
/// <item><c>CPhysicsObj::is_newer</c> (0x00451ad0) — the wraparound u16
@ -22,7 +22,7 @@ namespace AcDream.Core.Tests.Physics;
/// newer than the incoming one (equal passes).</item>
/// </list>
/// </summary>
public class MotionSequenceGateTests
public class PhysicsTimestampGateTests
{
// CPhysicsObj::is_newer(old, new): abs(new-old) > 0x7fff ? new < old : old < new.
[Theory]
@ -36,14 +36,17 @@ public class MotionSequenceGateTests
[InlineData((ushort)2, (ushort)0xFFFE, false)] // older across the seam
public void IsNewer_MatchesRetailWraparoundCompare(ushort oldStamp, ushort newStamp, bool expected)
{
Assert.Equal(expected, MotionSequenceGate.IsNewer(oldStamp, newStamp));
Assert.Equal(expected, PhysicsTimestampGate.IsNewer(oldStamp, newStamp));
}
[Fact]
public void FirstEvent_WithFreshMovementSequence_IsAccepted()
public void EventBeforeCreateObjectSeed_IsRejected()
{
var gate = new MotionSequenceGate();
Assert.True(gate.TryAcceptMovementEvent(instanceSeq: 0, movementSeq: 1, serverControlSeq: 0));
var gate = new PhysicsTimestampGate();
Assert.False(gate.TryAcceptMovementEvent(instance: 0, movement: 1, serverControlledMove: 0));
Assert.False(gate.TryAcceptStateEvent(instance: 0, state: 1));
Assert.False(gate.TryAcceptVectorEvent(instance: 0, vector: 1));
Assert.False(gate.TryAcceptObjDescEvent(instance: 0, objDesc: 1));
}
[Fact]
@ -51,7 +54,8 @@ public class MotionSequenceGateTests
{
// Gate 1 accepts strictly-newer only — an identical movementSeq is a
// duplicate delivery, not a new command.
var gate = new MotionSequenceGate();
var gate = new PhysicsTimestampGate();
SeedMovement(gate, instance: 0, movement: 0, serverControl: 0);
Assert.True(gate.TryAcceptMovementEvent(0, 5, 0));
Assert.False(gate.TryAcceptMovementEvent(0, 5, 0));
}
@ -59,7 +63,8 @@ public class MotionSequenceGateTests
[Fact]
public void StaleMovementSequence_IsDropped_ThenNewerAccepted()
{
var gate = new MotionSequenceGate();
var gate = new PhysicsTimestampGate();
SeedMovement(gate, instance: 0, movement: 0, serverControl: 0);
Assert.True(gate.TryAcceptMovementEvent(0, 5, 0));
Assert.False(gate.TryAcceptMovementEvent(0, 4, 0)); // reordered straggler
Assert.True(gate.TryAcceptMovementEvent(0, 6, 0));
@ -71,8 +76,8 @@ public class MotionSequenceGateTests
// Stamps live in u16 space and wrap; retail seeds them from the
// CreateObject PhysicsDesc timestamp block, so a gate near the seam
// must accept the post-wrap values as newer.
var gate = new MotionSequenceGate();
gate.Seed(instanceSeq: 0, movementSeq: 0xFFFD, serverControlSeq: 0);
var gate = new PhysicsTimestampGate();
SeedMovement(gate, instance: 0, movement: 0xFFFD, serverControl: 0);
Assert.True(gate.TryAcceptMovementEvent(0, 0xFFFE, 0));
Assert.True(gate.TryAcceptMovementEvent(0, 2, 0)); // 2 is newer than 0xFFFE by wrap rule
Assert.False(gate.TryAcceptMovementEvent(0, 0xFFFE, 0)); // and the old stamp is now stale
@ -87,34 +92,48 @@ public class MotionSequenceGateTests
// (ACE WorldObject_Networking.cs:411-420 writes all 9 in enum
// order), so the first UM after spawn is judged against the
// seeded stamp, not zero.
var gate = new MotionSequenceGate();
gate.Seed(instanceSeq: 3, movementSeq: 0x9000, serverControlSeq: 2);
var gate = new PhysicsTimestampGate();
SeedMovement(gate, instance: 3, movement: 0x9000, serverControl: 2);
Assert.True(gate.TryAcceptMovementEvent(3, 0x9001, 2));
Assert.False(gate.TryAcceptMovementEvent(3, 0x8FFF, 2)); // pre-spawn straggler
}
[Fact]
public void Reseed_WithOlderStamps_DoesNotRegress()
public void SameGenerationCreateObject_DoesNotConsumeIndividualChannels()
{
// The #138 rehydrate path replays RETAINED CreateObject spawns
// through the normal spawn handler (GameWindow.RehydrateServerEntities
// ForLandblock) — re-seeding from that cached spawn must not roll the
// live stamps backward, or a post-rehydrate straggler UM would be
// accepted as fresh. First Seed adopts wholesale (fresh object);
// later Seeds only advance.
var gate = new MotionSequenceGate();
gate.Seed(instanceSeq: 3, movementSeq: 0x9000, serverControlSeq: 2);
// Retail HandleCreateObject keeps an equal-instance object and routes
// each field through its ordinary channel handler. Classifying the
// CreateObject itself must therefore consume no channel timestamp.
var gate = new PhysicsTimestampGate();
SeedMovement(gate, instance: 3, movement: 0x9000, serverControl: 2);
Assert.True(gate.TryAcceptMovementEvent(3, 0x9001, 2));
gate.Seed(instanceSeq: 3, movementSeq: 0x8000, serverControlSeq: 2); // stale replay
Assert.False(gate.TryAcceptMovementEvent(3, 0x9001, 2)); // still a duplicate
var disposition = gate.SeedForCreateObject(
0, 0x9002, 0, 0, 0, 2, 0, 0, 3);
Assert.Equal(CreateObjectTimestampDisposition.ExistingGeneration, disposition);
Assert.True(gate.TryAcceptMovementEvent(3, 0x9002, 2));
gate.Seed(instanceSeq: 4, movementSeq: 0x9010, serverControlSeq: 2); // genuine re-create
SeedMovement(gate, instance: 4, movement: 0x9010, serverControl: 2); // genuine re-create
Assert.False(gate.TryAcceptMovementEvent(3, 0x9011, 2)); // old incarnation stale
Assert.True(gate.TryAcceptMovementEvent(4, 0x9011, 2));
}
[Fact]
public void SameGenerationCreateObject_MixedChannelsAreAcceptedIndependently()
{
var gate = new PhysicsTimestampGate();
gate.SeedForCreateObject(10, 10, 10, 10, 20, 5, 0, 10, 3);
Assert.Equal(CreateObjectTimestampDisposition.ExistingGeneration,
gate.SeedForCreateObject(9, 11, 11, 9, 20, 5, 0, 11, 3));
Assert.False(gate.TryAcceptPositionChannelEvent(3, 9));
Assert.True(gate.TryAcceptMovementEvent(3, 11, 5));
Assert.True(gate.TryAcceptStateEvent(3, 11));
Assert.False(gate.TryAcceptVectorEvent(3, 9));
Assert.True(gate.TryAcceptObjDescEvent(3, 11));
}
[Fact]
public void StaleServerControl_Drops_ButMovementStampStillAdvances()
{
@ -122,7 +141,8 @@ public class MotionSequenceGateTests
// (0x00509690: update_times[1] written at 0x005096dd, the SC compare
// runs after) — a movement event dropped for stale server-control
// still consumes its movement sequence.
var gate = new MotionSequenceGate();
var gate = new PhysicsTimestampGate();
SeedMovement(gate, instance: 0, movement: 0, serverControl: 0);
Assert.True(gate.TryAcceptMovementEvent(0, 1, 5));
Assert.False(gate.TryAcceptMovementEvent(0, 2, 4)); // sc=4 older than stored 5 → drop
Assert.False(gate.TryAcceptMovementEvent(0, 2, 5)); // movementSeq 2 was consumed by the drop
@ -134,7 +154,8 @@ public class MotionSequenceGateTests
{
// The SC gate drops only when the STORED stamp is strictly newer than
// the incoming one; equal means "same server-control era" and applies.
var gate = new MotionSequenceGate();
var gate = new PhysicsTimestampGate();
SeedMovement(gate, instance: 0, movement: 0, serverControl: 0);
Assert.True(gate.TryAcceptMovementEvent(0, 1, 7));
Assert.True(gate.TryAcceptMovementEvent(0, 2, 7));
}
@ -145,31 +166,171 @@ public class MotionSequenceGateTests
// The INSTANCE_TS gate runs at dispatch level, BEFORE
// CPhysics::SetObjectMovement — a stale-incarnation event must not
// touch the movement stamps.
var gate = new MotionSequenceGate();
var gate = new PhysicsTimestampGate();
SeedMovement(gate, instance: 2, movement: 0, serverControl: 0);
Assert.True(gate.TryAcceptMovementEvent(2, 1, 0));
Assert.False(gate.TryAcceptMovementEvent(1, 2, 0)); // stale incarnation
Assert.True(gate.TryAcceptMovementEvent(2, 2, 0)); // seq 2 was NOT consumed by the instance drop
}
[Fact]
public void NewerInstance_IsAdoptedAndApplied()
public void NewerInstance_IsDroppedUntilCreateObjectStartsGeneration()
{
// DIVERGENCE (register row added with this port): retail queues the
// blob until the newer incarnation exists (SmartBox::QueueBlobForObject,
// dispatch return 4); acdream adopts the newer instance stamp and
// applies immediately.
var gate = new MotionSequenceGate();
// Retail queues this blob; AD-32 records that acdream drops it until
// LiveEntityRuntime owns that queue. It must never touch the old body.
var gate = new PhysicsTimestampGate();
SeedMovement(gate, instance: 1, movement: 0, serverControl: 0);
Assert.True(gate.TryAcceptMovementEvent(1, 1, 0));
Assert.True(gate.TryAcceptMovementEvent(2, 2, 0)); // newer incarnation adopted
Assert.False(gate.TryAcceptMovementEvent(1, 3, 0)); // old incarnation now stale
Assert.False(gate.TryAcceptMovementEvent(2, 2, 0));
Assert.True(gate.TryAcceptMovementEvent(1, 2, 0)); // future event consumed nothing
}
[Fact]
public void InstanceCompare_WrapsAroundTheU16Seam()
{
var gate = new MotionSequenceGate();
gate.Seed(instanceSeq: 0xFFFE, movementSeq: 0, serverControlSeq: 0);
var gate = new PhysicsTimestampGate();
SeedMovement(gate, instance: 0xFFFE, movement: 0, serverControl: 0);
Assert.True(gate.TryAcceptMovementEvent(0xFFFE, 1, 0));
Assert.True(gate.TryAcceptMovementEvent(2, 2, 0)); // instance 2 newer than 0xFFFE by wrap
Assert.False(gate.TryAcceptMovementEvent(2, 2, 0)); // future incarnation queues in retail
Assert.Equal(CreateObjectTimestampDisposition.NewGeneration,
gate.SeedForCreateObject(0, 1, 0, 0, 0, 0, 0, 0, 2));
Assert.True(gate.TryAcceptMovementEvent(2, 2, 0));
}
[Fact]
public void StateAndVectorChannelsAdvanceIndependently()
{
var gate = new PhysicsTimestampGate();
gate.SeedForCreateObject(10, 20, 30, 40, 50, 60, 70, 80, 90);
Assert.True(gate.TryAcceptStateEvent(90, 31));
Assert.False(gate.TryAcceptStateEvent(90, 31));
Assert.True(gate.TryAcceptVectorEvent(90, 41));
Assert.False(gate.TryAcceptVectorEvent(90, 40));
}
[Fact]
public void PositionRollsBackWhenNewerTeleportAlreadyExists()
{
var gate = new PhysicsTimestampGate();
gate.SeedForCreateObject(10, 0, 0, 0, 20, 0, 0, 0, 1);
Assert.Equal(PositionTimestampDisposition.Rejected,
gate.TryAcceptPositionEvent(1, 11, 19, 0, isLocalPlayer: false));
Assert.Equal(PositionTimestampDisposition.Apply,
gate.TryAcceptPositionEvent(1, 11, 20, 0, isLocalPlayer: false));
}
[Fact]
public void FreshTeleportAndForcePositionAdvanceTheirOwnChannels()
{
var gate = new PhysicsTimestampGate();
gate.SeedForCreateObject(10, 0, 0, 0, 20, 0, 30, 0, 1);
Assert.Equal(PositionTimestampDisposition.ForcePosition,
gate.TryAcceptPositionEvent(1, 9, 20, 31, isLocalPlayer: true));
Assert.Equal((ushort)9, gate.PositionTimestamp);
Assert.Equal((ushort)20, gate.TeleportTimestamp);
Assert.Equal(PositionTimestampDisposition.Rejected,
gate.TryAcceptPositionEvent(1, 9, 20, 31, isLocalPlayer: true));
}
[Fact]
public void FreshForceWithNewerTeleport_UsesNormalPositionPath()
{
var gate = new PhysicsTimestampGate();
gate.SeedForCreateObject(10, 0, 0, 0, 20, 0, 30, 0, 1);
Assert.Equal(PositionTimestampDisposition.Apply,
gate.TryAcceptPositionEvent(1, 11, 21, 31, isLocalPlayer: true));
Assert.Equal((ushort)11, gate.PositionTimestamp);
Assert.Equal((ushort)21, gate.TeleportTimestamp);
Assert.Equal((ushort)31, gate.ForcePositionTimestamp);
}
[Fact]
public void FreshForceIsConsumedWhenOlderTeleportRejectsPose()
{
var gate = new PhysicsTimestampGate();
gate.SeedForCreateObject(10, 0, 0, 0, 20, 0, 30, 0, 1);
Assert.Equal(PositionTimestampDisposition.Rejected,
gate.TryAcceptPositionEvent(1, 11, 19, 31, isLocalPlayer: true));
Assert.Equal((ushort)10, gate.PositionTimestamp);
Assert.Equal((ushort)20, gate.TeleportTimestamp);
Assert.Equal((ushort)31, gate.ForcePositionTimestamp);
}
[Fact]
public void ParentPickupAndPositionShareOnePositionChannel()
{
var gate = new PhysicsTimestampGate();
gate.SeedForCreateObject(10, 0, 0, 0, 20, 0, 0, 0, 1);
Assert.True(gate.TryAcceptPositionChannelEvent(1, 11));
Assert.Equal(PositionTimestampDisposition.Rejected,
gate.TryAcceptPositionEvent(1, 11, 20, 0, isLocalPlayer: false));
Assert.False(gate.TryAcceptPositionChannelEvent(1, 10));
Assert.True(gate.TryAcceptPositionChannelEvent(1, 12));
}
[Fact]
public void NewCreateObjectGenerationReplacesLowerChannelStampsWholesale()
{
var gate = new PhysicsTimestampGate();
gate.SeedForCreateObject(1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1);
var disposition = gate.SeedForCreateObject(1, 1, 1, 1, 1, 1, 1, 1, 2);
Assert.Equal(CreateObjectTimestampDisposition.NewGeneration, disposition);
Assert.True(gate.TryAcceptStateEvent(2, 2));
Assert.True(gate.TryAcceptVectorEvent(2, 2));
Assert.Equal(PositionTimestampDisposition.Apply,
gate.TryAcceptPositionEvent(2, 2, 1, 1, isLocalPlayer: false));
}
[Fact]
public void StaleCreateObjectAndDeleteCannotAffectCurrentGeneration()
{
var gate = new PhysicsTimestampGate();
gate.SeedForCreateObject(1, 1, 1, 1, 1, 1, 1, 1, 2);
Assert.Equal(CreateObjectTimestampDisposition.StaleGeneration,
gate.SeedForCreateObject(500, 500, 500, 500, 500, 500, 500, 500, 1));
Assert.False(gate.TryAcceptDeleteEvent(1));
Assert.True(gate.TryAcceptDeleteEvent(2));
Assert.False(gate.TryAcceptDeleteEvent(2, isLocalPlayer: true));
}
[Fact]
public void ObjDescRequiresCurrentInstanceAndStrictlyNewerSequence()
{
var gate = new PhysicsTimestampGate();
gate.SeedForCreateObject(0, 0, 0, 0, 0, 0, 0, 0xFFFE, 7);
Assert.False(gate.TryAcceptObjDescEvent(6, 0xFFFF));
Assert.True(gate.TryAcceptObjDescEvent(7, 0xFFFF));
Assert.True(gate.TryAcceptObjDescEvent(7, 0));
Assert.False(gate.TryAcceptObjDescEvent(7, 0));
}
[Fact]
public void PlayerTeleportStartChecksButDoesNotAdvanceTeleportChannel()
{
var gate = new PhysicsTimestampGate();
gate.SeedForCreateObject(0, 0, 0, 0, 10, 0, 0, 0, 1);
Assert.True(gate.IsFreshTeleportStart(10));
Assert.False(gate.IsFreshTeleportStart(9));
Assert.True(gate.IsFreshTeleportStart(11));
Assert.True(gate.IsFreshTeleportStart(11));
Assert.Equal((ushort)10, gate.TeleportTimestamp);
}
private static void SeedMovement(
PhysicsTimestampGate gate,
ushort instance,
ushort movement,
ushort serverControl) =>
gate.SeedForCreateObject(0, movement, 0, 0, 0, serverControl, 0, 0, instance);
}

View file

@ -0,0 +1,34 @@
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
public sealed class PhysicsStateFlagsTests
{
[Theory]
[InlineData(PhysicsStateFlags.Static, 0x00000001u)]
[InlineData(PhysicsStateFlags.Ethereal, 0x00000004u)]
[InlineData(PhysicsStateFlags.ReportCollisions, 0x00000008u)]
[InlineData(PhysicsStateFlags.IgnoreCollisions, 0x00000010u)]
[InlineData(PhysicsStateFlags.NoDraw, 0x00000020u)]
[InlineData(PhysicsStateFlags.Missile, 0x00000040u)]
[InlineData(PhysicsStateFlags.Pushable, 0x00000080u)]
[InlineData(PhysicsStateFlags.AlignPath, 0x00000100u)]
[InlineData(PhysicsStateFlags.PathClipped, 0x00000200u)]
[InlineData(PhysicsStateFlags.Gravity, 0x00000400u)]
[InlineData(PhysicsStateFlags.Lighting, 0x00000800u)]
[InlineData(PhysicsStateFlags.ParticleEmitter, 0x00001000u)]
[InlineData(PhysicsStateFlags.Hidden, 0x00004000u)]
[InlineData(PhysicsStateFlags.ScriptedCollision, 0x00008000u)]
[InlineData(PhysicsStateFlags.HasPhysicsBsp, 0x00010000u)]
[InlineData(PhysicsStateFlags.Inelastic, 0x00020000u)]
[InlineData(PhysicsStateFlags.HasDefaultAnim, 0x00040000u)]
[InlineData(PhysicsStateFlags.HasDefaultScript, 0x00080000u)]
[InlineData(PhysicsStateFlags.Cloaked, 0x00100000u)]
[InlineData(PhysicsStateFlags.ReportAsEnvironment, 0x00200000u)]
[InlineData(PhysicsStateFlags.EdgeSlide, 0x00400000u)]
[InlineData(PhysicsStateFlags.Sledding, 0x00800000u)]
[InlineData(PhysicsStateFlags.Frozen, 0x01000000u)]
public void ValuesMatchRetailPhysicsStateEnum(PhysicsStateFlags flag, uint expected) =>
Assert.Equal(expected, (uint)flag);
}

View file

@ -0,0 +1,31 @@
using System.Numerics;
using AcDream.Core.Physics;
namespace AcDream.Core.Tests.Physics;
public sealed class PositionFrameValidationTests
{
[Fact]
public void ValidPositionRequiresInboundCellAndRetailFrameValidity()
{
Assert.True(PositionFrameValidation.IsValid(
0x0101FFFFu, new Vector3(1f, 2f, 3f), Quaternion.Identity));
Assert.False(PositionFrameValidation.IsValid(
0u, new Vector3(1f, 2f, 3f), Quaternion.Identity));
Assert.False(PositionFrameValidation.IsValid(
0x0101FFFFu, new Vector3(float.NaN, 2f, 3f), Quaternion.Identity));
Assert.False(PositionFrameValidation.IsValid(
0x0101FFFFu, new Vector3(1f, 2f, 3f), new Quaternion(0f, 0f, 0f, 0.9f)));
}
[Theory]
[InlineData(1.001f, true)]
[InlineData(1.0011f, false)]
public void QuaternionSquaredNormUsesRetailTolerance(float squaredNorm, bool expected)
{
var rotation = new Quaternion(0f, 0f, 0f, MathF.Sqrt(squaredNorm));
Assert.Equal(expected, PositionFrameValidation.IsValid(
0x0101FFFFu, Vector3.Zero, rotation));
}
}

View file

@ -5,6 +5,25 @@ namespace AcDream.Core.Tests.Selection;
public sealed class SelectionStateTests
{
[Fact]
public void ResetClearsCurrentAndSelectionHistoryForNextSession()
{
var state = new SelectionState();
state.Select(0x50000001u, SelectionChangeSource.World);
state.Select(0x50000002u, SelectionChangeSource.World);
SelectionTransition transition = default;
state.Changed += value => transition = value;
Assert.True(state.Reset());
Assert.Null(state.SelectedObjectId);
Assert.Null(state.PreviousObjectId);
Assert.Null(state.PreviousValidObjectId);
Assert.Equal(0x50000002u, transition.PreviousObjectId);
Assert.Equal(SelectionChangeReason.SessionReset, transition.Reason);
Assert.False(state.SelectPrevious());
}
[Fact]
public void Select_CommitsOneTransitionAndTracksPreviousLikeRetail()
{

View file

@ -216,7 +216,7 @@ public class GpuWorldStateTests
{
// A normal server object (door) in the pending bucket is NOT persistent;
// it must still be dropped (and is recoverable via #138 re-hydrate from
// _lastSpawnByGuid, not via the rescue path).
// the retained inbound spawn snapshot, not via the rescue path).
var state = new GpuWorldState();
var door = MakeServerEntity(id: 2, serverGuid: 0x7A9B4001u); // not marked persistent
state.AppendLiveEntity(0xA9B40011u, door);