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>
335 lines
14 KiB
C#
335 lines
14 KiB
C#
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);
|
|
}
|