Follow-up to C5b (735f0a72). The retail-conformance review passed, so no production behaviour line moved: the flag truth table and the refreshPosition:false withhold are untouched. This is blast radius, test discrimination and documentation fidelity - plus two findings I could not confirm and am rebutting rather than complying with. D3 - THE PUBLISH-CONSERVATION TEST DID NOT DISCRIMINATE. The reviewer was right and it was the worst finding here: proof obligation 3's test passed identically with C5b reverted. Its only delta assertion FILTERED (Assert.Single(deltas, Rebucketed && parentGuid)), so the pre-C5b stream [Rebucketed] and the post-C5b stream [Updated, Rebucketed] both satisfied it, and childSpatialBefore+1 held in both worlds because whichever site did not move the cell propagated idempotently. It now asserts the complete ordered parent stream plus each element's CellId and Position.ObjCellId. Sabotage: restoring refreshPosition:acceptedPosition turns it red (it was green before), together with the withhold test and the new L5 test. That cardinality change was itself unfiled and is now AP-147: a cell-changing accepted Position publishes TWO entity deltas where it published one, and the intermediate Updated pairs the OLD CellId with the NEW wire Position - a torn pair that did not exist pre-C5b, since both halves used to move inside one publish. No production consumer reads a delta's paired fields, but a recorder/plugin/bot event log would capture it. The row states why suppressing the Updated is not available at that layer (the merge cannot know whether its caller reaches W2). D4 - THE PROJECTILE DOC COMMENT WAS FALSE AND ITS RETAIL ARGUMENT WAS INVERTED. SyncPresentationFromResolvedBody claimed record.FullCellId is "the WIRE cell ... stamped by the merge's RefreshDerivedState/SetFullCell, before classification ever runs" and argued from retail's store_position @0x00515CE2 that the destination cell is the right one. C5b falsified the premise; the missile arm also returns before W2, so nothing stamps the wire cell for a projectile at all. Rewritten. The honest conclusion, which the old text would have called wrong: on a stored outcome presentation now pairs the DESTINATION world position with the SOURCE cell. That is not a choice this method can make differently - StoreAcceptedDestinationPose writes only Position/Orientation, so record.FullCellId and body.CellPosition.ObjCellId now hold the same source cell and reading either yields the same value. The divergence is AP-138 item (1)'s store-writes-pose-but-not-cell residual, retiring via #309, not a field choice here. Projecting the wire cell instead would invent a residency the placement declined - the AP-1 shape C5b closed. L3/L4/L5 - PINNING GAPS, ALL THREE CONFIRMED AND CLOSED. L3: the matrix's oracle passed HasAnimations as a literal, so the merge's old.MotionTableId ?? old.Physics?.MotionTableId and RuntimeAcceptedPositionRouteRequests.Build's canonical-snapshot twin were textually identical and pinned by nothing. The oracle is now BUILT by the production constructor. L4: every fixture set both MotionTableId halves to the same value, so deleting either operand of the ?? was undetectable while the production comment said the mixed case is the real-world one. Six mixed rows added, including the explicit-zero row (a present-but-zero top half is not null, so ?? never reaches the physics half). L5: the retained Rebucketed ternary had zero coverage through TryApplyPosition - every restoreCancelledPark test called Forget directly. Now driven through the real merge, with the wire cell deliberately the SOURCE while the park's committed body cell is the DESTINATION, so the restored residency can only have come from the rollback. Sabotage (each red, each restored): merge ?? -> top half only, 1 red; -> physics half only, 2 red; Build's ?? -> physics half only, 2 red; ternary -> constant Updated, exactly the L5 test red. L1/L2 - THE MISSING TEST IS ADDED; THE DEFECT IS NOT THERE. The reviewer was right that C5b's "no fixture covers pickup at that layer" was inaccurate - LiveEntityNetworkOnPositionCollapseMatrixTests drives the real OnPosition at ~26 sites - and the end-to-end test is added: withdraw -> accepted Position -> IsSpatiallyProjected && FullCellId == wireCell, both guid classes. But ChildUnparentDisposition.Pending is NOT a live defect, because it is production-unreachable. The sole production _withdrawProjection binding (LivePresentationComposition.cs:599) is LiveEntityProjectionWithdrawalController.WithdrawExact, whose only Pending mint is inside its catch block and therefore always carries a non-null Failure - and AdvanceUnparentTransition rethrows at EquippedChildRenderController.cs:1307 BEFORE the return Pending at :1309. The named drop scenario does not reach it anyway (BeginDetachedRemoval has already emptied the capture list) and would be correct if it did: a previously-equipped child is LegacyImmediate, so the FullCellId != 0u gate at DatLiveEntityProjectionMaterializer.cs:767 is never consulted and re-projection uses the wire cell at LiveEntityRuntime.cs:824. Measured while building that test, and NOT what C5b assumed: W2 and W3 are REDUNDANT on the remote tail. Sabotaging W2 alone - adopting the committed cell instead of the wire cell, OR skipping the rebucket outright - leaves the whole file green, because W3's RemoteMotion.CellId write reads through to canonical FullCellId via CommitCanonicalCell, whose CellCommitted recovery re-installs the bucket. Only removing BOTH goes red, and then the new test is the only red in the file. So it is named for what it pins, and AD-60 is amended with the measurement: neither channel is individually load-bearing, so a future retirement of one is caught by nothing else. D2 - REBUTTED, WITH THE REAL GAP FILED INSTEAD. The reviewer's hypothesis was that TryApplyInitialCreateCompletionPresentation's staleness guard lost its ability to detect an intervening steady-state Position when C5b stopped the merge stamping the wire cell, and asked for a PositionAuthorityVersion term. I do not think that is right and did not add it. The receipt's facts are the canonical BODY's pose and cell at publish (PublishExecutorCompletion builds both from the record). Exactly two owners can move them: a Runtime SetPosition commit/withdrawal, every one of which calls AdvancePlacementCommit - the only caller family is RuntimeSetPositionState - and a rebucket, which moves FullCellId. Both are already covered by the two existing terms. An accepted steady-state Position is neither, and C5b did not make it one: the merge refreshes the snapshot and advances PositionAuthorityVersion but never wrote the body, and the App generic tail writes the RENDER entity. The wire-cell half stays covered because W2/W3 commit it in the same call; the paths that return before them leave the record at the last committed cell, which IS the receipt's own cell - correctly not a supersession. Adding the term would decline receipts whose facts are still true, on the entity's FIRST world-visible moment: the pose write and RebucketLiveEntityPresentationOnly would be skipped while TryPublishPlace still publishes, so a packet returning before the render write would leave the sidecar visible at its materialized pose in a wrong bucket. That is the handoff's own "removed the invariant failure while leaving the bug" shape. There IS one supersession neither term covers, and it predates C5b: RuntimeRemotePlacementDriveController.StoreAcceptedDestinationPose writes body.Position/Orientation on the far-snap Refused/Contention arm with no placement commit and no cell move. Filed as #323 with the FIFO-blocking argument for why a receipt can still be pending when it lands, an explicit "not established as reachable", and an explicit "do not fix it with PositionAuthorityVersion". The guard's comment now carries the whole argument instead of one sentence. S1 - DANGLING POINTER CLOSED. InboundPhysicsStateController.cs:610 still said the two-callers-one-rule debt was "tracked for the eventual cutover unification ... See docs/ISSUES.md", which pointed at nothing after C5b closed #275 without a successor. Filed #322, cited from both the comment and #275's closure, including why widening TryApplyPosition's signature to take a route would be the wrong unification. AP-138 amended: C5b staled its round-3 measurement that "both accepted-Position callers commit the accepted wire cell to record.FullCellId before submitting". Route 2 submits from TryExecuteAcceptedLocalPosition ahead of W2, so on a first submit PlacementTouchesPrefix's CurrentCellId arm now names the SOURCE landblock, not the destination. Confined to which prefix the quiescence pre-flight matches, which that row already established is not the correctness mechanism. GATES. Release build 0 errors. Complete suite 11,134 passed / 4 skipped / 0 failed, from the 11,125 / 4 baseline ated806997: net +9, all new tests, no test deleted or weakened, no new skip. Runtime.Tests 1195 -> 1202 (+6 mixed-motion-table rows, +1 park-rollback fact); App.Tests 4132 -> 4134 (+2 guid rows). None of #302/#308/#321 appeared. Not connected-gated - nothing here changes runtime behaviour. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1176 lines
46 KiB
C#
1176 lines
46 KiB
C#
using System.Numerics;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Runtime;
|
|
using AcDream.Runtime.Entities;
|
|
using AcDream.Runtime.Physics;
|
|
|
|
namespace AcDream.Runtime.Tests.Entities;
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// C5b (#275) rewrite of the pre-classification test: the retail-zero
|
|
/// rule (<c>PositionPack::UnPack</c> @0x00516740 initializes an absent
|
|
/// placement id to zero and <c>HandleReceivedPosition</c> forwards that
|
|
/// exact value to <c>SetPlacementFrame</c>) is only reachable when the
|
|
/// gate ABOVE it passes — <c>CPhysicsObj::HasAnims(arg2) == 0</c>
|
|
/// @0x00454137. The seed is therefore explicitly NON-animated; the
|
|
/// animated counterpart is
|
|
/// <see cref="ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame"/>.
|
|
/// </summary>
|
|
[Fact]
|
|
public void PositionPlacementAbsentAndPresentZeroBothApplyRetailZero()
|
|
{
|
|
var controller = new InboundPhysicsStateController();
|
|
WorldSession.EntitySpawn seed = Spawn(
|
|
0x70000009u,
|
|
7,
|
|
20,
|
|
1,
|
|
Position(0x0101FFFFu, 10f),
|
|
0x408u,
|
|
motionTableId: null);
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// C5b (#275) / AP-131 retirement, half 1. Retail gates
|
|
/// <c>CPhysicsObj::SetPlacementFrame</c> @0x00454142 on
|
|
/// <c>CPhysicsObj::HasAnims(arg2) == 0</c> @0x00454137: an ANIMATED body
|
|
/// never takes the wire placement frame, because its animation sequencer
|
|
/// owns the pose. Before C5b the steady-state merge stamped it anyway,
|
|
/// which is the animation snap/reset half of AP-131.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame()
|
|
{
|
|
var controller = new InboundPhysicsStateController();
|
|
WorldSession.EntitySpawn seed = Spawn(
|
|
0x8000C001u,
|
|
7,
|
|
20,
|
|
1,
|
|
Position(0x0101FFFFu, 10f),
|
|
0x408u,
|
|
motionTableId: 0x09000001u);
|
|
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),
|
|
Velocity: null,
|
|
PlacementId: 5,
|
|
IsGrounded: true,
|
|
InstanceSequence: 7,
|
|
PositionSequence: 21,
|
|
TeleportSequence: 0,
|
|
ForcePositionSequence: 0),
|
|
isLocalPlayer: false,
|
|
forcePositionRotation: null,
|
|
currentLocalVelocity: null,
|
|
out PositionTimestampDisposition disposition,
|
|
out WorldSession.EntitySpawn accepted,
|
|
out _));
|
|
|
|
Assert.Equal(PositionTimestampDisposition.Apply, disposition);
|
|
// The gate above SetPlacementFrame never opened.
|
|
Assert.Equal((uint)7, accepted.PlacementId);
|
|
Assert.Equal((uint)7, accepted.Physics!.Value.AnimationFrame);
|
|
// The rest of the merge is unaffected: pose and POSITION_TS still land.
|
|
Assert.Equal(20f, accepted.Position!.Value.PositionX);
|
|
Assert.Equal((ushort)21, accepted.PositionSequence);
|
|
// unset_parent @0x00454129 is unconditional on this path.
|
|
Assert.Null(accepted.ParentGuid);
|
|
}
|
|
|
|
/// <summary>
|
|
/// C5b (#275) / AP-131 retirement, half 1 (the other direction). A
|
|
/// NON-animated body reaches <c>SetPlacementFrame</c> @0x00454142 and
|
|
/// takes the wire frame verbatim.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame()
|
|
{
|
|
var controller = new InboundPhysicsStateController();
|
|
WorldSession.EntitySpawn seed = Spawn(
|
|
0x8000C002u,
|
|
7,
|
|
20,
|
|
1,
|
|
Position(0x0101FFFFu, 10f),
|
|
0x408u,
|
|
motionTableId: null);
|
|
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),
|
|
Velocity: null,
|
|
PlacementId: 5,
|
|
IsGrounded: true,
|
|
InstanceSequence: 7,
|
|
PositionSequence: 21,
|
|
TeleportSequence: 0,
|
|
ForcePositionSequence: 0),
|
|
isLocalPlayer: false,
|
|
forcePositionRotation: null,
|
|
currentLocalVelocity: null,
|
|
out PositionTimestampDisposition disposition,
|
|
out WorldSession.EntitySpawn accepted,
|
|
out _));
|
|
|
|
Assert.Equal(PositionTimestampDisposition.Apply, disposition);
|
|
Assert.Equal((uint)5, accepted.PlacementId);
|
|
Assert.Equal((uint)5, accepted.Physics!.Value.AnimationFrame);
|
|
}
|
|
|
|
/// <summary>
|
|
/// C5b (#275) / AP-131 retirement, half 2. Retail's Gate A
|
|
/// (@0x0045400C, the local-player FORCE_POSITION self-echo shortcut)
|
|
/// returns @0x0045409D BEFORE <c>CPhysicsObj::unset_parent</c>
|
|
/// @0x00454129, so a ForcePosition never unparents. Parent RETENTION is
|
|
/// the discriminating observable for the force row: the placement-frame
|
|
/// half is inert there (<c>appliedPlacement</c> keeps
|
|
/// <c>old.PlacementId</c> under either flag value), so sabotaging it on a
|
|
/// ForcePosition proves nothing.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment()
|
|
{
|
|
var controller = new InboundPhysicsStateController();
|
|
WorldSession.EntitySpawn seed = WithTimestamps(
|
|
Spawn(0x50000021u, 3, 10, 1, Position(0x0101FFFFu, 10f), 0x408u),
|
|
teleport: 10,
|
|
forcePosition: 0);
|
|
seed = seed with
|
|
{
|
|
ParentGuid = 0x70004444u,
|
|
ParentLocation = 9u,
|
|
Physics = seed.Physics!.Value with
|
|
{
|
|
Parent = new PhysicsAttachment(0x70004444u, 9u),
|
|
},
|
|
};
|
|
controller.AcceptCreate(seed);
|
|
var preserved = new Quaternion(0.1f, 0.2f, 0.3f, 0.9f);
|
|
|
|
Assert.True(controller.TryApplyPosition(
|
|
PositionUpdate(
|
|
seed.Guid,
|
|
instance: 3,
|
|
position: 9,
|
|
teleport: 10,
|
|
forcePosition: 1),
|
|
isLocalPlayer: true,
|
|
forcePositionRotation: preserved,
|
|
currentLocalVelocity: Vector3.Zero,
|
|
out PositionTimestampDisposition disposition,
|
|
out WorldSession.EntitySpawn accepted,
|
|
out _));
|
|
|
|
Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition);
|
|
Assert.Equal(0x70004444u, accepted.ParentGuid);
|
|
Assert.Equal(9u, accepted.ParentLocation);
|
|
Assert.Equal(
|
|
new PhysicsAttachment(0x70004444u, 9u),
|
|
accepted.Physics!.Value.Parent);
|
|
// Heading preservation (BlipPlayer's get_heading/set_heading pair
|
|
// @0x00454056-68) is unchanged by C5b.
|
|
Assert.Equal(preserved.W, accepted.Position!.Value.RotationW);
|
|
Assert.Equal(preserved.X, accepted.Position!.Value.RotationX);
|
|
}
|
|
|
|
/// <summary>
|
|
/// C5b (#275) proof obligation 1: the merge's two pre-placement flags ARE
|
|
/// <see cref="RuntimeAuthoritativePositionRouteClassifier"/>'s
|
|
/// <c>ApplyPlacementFrameBeforeRouting</c>/<c>UnparentBeforeRouting</c>
|
|
/// rows. The production classifier is the oracle — this test does not
|
|
/// re-encode the truth table, it drives both computations over the packet
|
|
/// matrix and asserts the merged snapshot equals what the route's own
|
|
/// flags would have produced. Dual parent classes (player <c>0x5…</c> and
|
|
/// creature <c>0x8…</c>) per the #319 discipline.
|
|
/// </summary>
|
|
[Theory]
|
|
// guid, isLocalPlayer, animated, parented, force
|
|
[InlineData(0x50000031u, true, true, true, false)]
|
|
[InlineData(0x50000032u, true, true, false, false)]
|
|
[InlineData(0x50000033u, true, false, true, false)]
|
|
[InlineData(0x50000034u, true, false, false, false)]
|
|
[InlineData(0x50000035u, true, true, true, true)]
|
|
[InlineData(0x50000036u, true, true, false, true)]
|
|
[InlineData(0x50000037u, true, false, true, true)]
|
|
[InlineData(0x50000038u, true, false, false, true)]
|
|
[InlineData(0x80000031u, false, true, true, false)]
|
|
[InlineData(0x80000032u, false, true, false, false)]
|
|
[InlineData(0x80000033u, false, false, true, false)]
|
|
[InlineData(0x80000034u, false, false, false, false)]
|
|
public void MergedPrePlacementFieldsMatchTheClassifiedRouteFlags(
|
|
uint guid,
|
|
bool isLocalPlayer,
|
|
bool animated,
|
|
bool parented,
|
|
bool force)
|
|
{
|
|
WorldSession.EntitySpawn seed = WithTimestamps(
|
|
Spawn(
|
|
guid,
|
|
3,
|
|
10,
|
|
1,
|
|
Position(0x0101FFFFu, 10f),
|
|
0x408u,
|
|
motionTableId: animated ? 0x09000001u : null),
|
|
teleport: 10,
|
|
forcePosition: 0);
|
|
seed = seed with
|
|
{
|
|
PlacementId = 7,
|
|
Physics = seed.Physics!.Value with { AnimationFrame = 7 },
|
|
};
|
|
if (parented)
|
|
{
|
|
seed = seed with
|
|
{
|
|
ParentGuid = 0x70004444u,
|
|
ParentLocation = 9u,
|
|
Physics = seed.Physics!.Value with
|
|
{
|
|
Parent = new PhysicsAttachment(0x70004444u, 9u),
|
|
},
|
|
};
|
|
}
|
|
|
|
WorldSession.EntityPositionUpdate update = new(
|
|
guid,
|
|
Position(0x0101FFFFu, 20f),
|
|
Velocity: null,
|
|
PlacementId: 5,
|
|
IsGrounded: true,
|
|
InstanceSequence: 3,
|
|
PositionSequence: force ? (ushort)9 : (ushort)11,
|
|
TeleportSequence: 10,
|
|
ForcePositionSequence: force ? (ushort)1 : (ushort)0);
|
|
|
|
var merging = new InboundPhysicsStateController();
|
|
merging.AcceptCreate(seed);
|
|
Assert.True(merging.TryApplyPosition(
|
|
update,
|
|
isLocalPlayer,
|
|
forcePositionRotation: isLocalPlayer ? Quaternion.Identity : null,
|
|
currentLocalVelocity: isLocalPlayer ? Vector3.Zero : null,
|
|
out PositionTimestampDisposition disposition,
|
|
out WorldSession.EntitySpawn merged,
|
|
out AcceptedPhysicsTimestamps timestamps));
|
|
Assert.Equal(
|
|
force
|
|
? PositionTimestampDisposition.ForcePosition
|
|
: PositionTimestampDisposition.Apply,
|
|
disposition);
|
|
|
|
// The oracle: the production classifier, fed the same wire packet and
|
|
// the same static HasAnimations proxy.
|
|
//
|
|
// C5b review L3 (2026-08-05): the request is now BUILT by the
|
|
// production constructor rather than hand-written. The old version
|
|
// passed `HasAnimations: animated` as a literal, so the merge's
|
|
// `old.MotionTableId ?? old.Physics?.MotionTableId` and
|
|
// RuntimeAcceptedPositionRouteRequests.Build's canonical-snapshot
|
|
// equivalent were textually identical and pinned by nothing — either
|
|
// could have been edited without a red test. Routing the oracle
|
|
// through Build makes the equality of the two expressions the thing
|
|
// this matrix actually asserts.
|
|
using RuntimeEntityObjectLifetime oracle = OracleLifetime();
|
|
RuntimeEntityRecord canonical = RegisterOracleSnapshot(oracle, seed);
|
|
RuntimeAuthoritativePositionRoute route =
|
|
RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition(
|
|
RuntimeAcceptedPositionRouteRequests.Build(
|
|
new RuntimeGenerationToken(7),
|
|
canonical,
|
|
new RuntimeEntityKey(guid, 1),
|
|
update,
|
|
isLocalPlayer
|
|
? RuntimePositionEntityKind.LocalPlayer
|
|
: RuntimePositionEntityKind.Remote,
|
|
RuntimeAcceptedPositionSource.PositionEvent,
|
|
disposition,
|
|
timestamps.PreviousTeleport,
|
|
timestamps.Teleport,
|
|
playerDistance: 0f,
|
|
usePositionFromServer: true,
|
|
committedCellId: 0x0101FFFFu));
|
|
|
|
// Rebuild what the classified route would have installed, through the
|
|
// SAME production merge, and require field equality.
|
|
var classified = new InboundPhysicsStateController();
|
|
classified.AcceptCreate(seed);
|
|
Assert.True(classified.ApplyAcceptedPositionSnapshot(
|
|
guid,
|
|
update,
|
|
disposition,
|
|
timestamps,
|
|
isLocalPlayer,
|
|
isLocalPlayer ? Quaternion.Identity : null,
|
|
isLocalPlayer ? Vector3.Zero : null,
|
|
installPlacementFrame: route.ApplyPlacementFrameBeforeRouting,
|
|
clearParent: route.UnparentBeforeRouting,
|
|
out WorldSession.EntitySpawn expected));
|
|
|
|
Assert.Equal(expected.PlacementId, merged.PlacementId);
|
|
Assert.Equal(
|
|
expected.Physics!.Value.AnimationFrame,
|
|
merged.Physics!.Value.AnimationFrame);
|
|
Assert.Equal(expected.ParentGuid, merged.ParentGuid);
|
|
Assert.Equal(expected.ParentLocation, merged.ParentLocation);
|
|
Assert.Equal(
|
|
expected.Physics!.Value.Parent,
|
|
merged.Physics!.Value.Parent);
|
|
}
|
|
|
|
[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 AcceptedPhysicsTimestamps normalTimestamps));
|
|
Assert.Equal(PositionTimestampDisposition.Apply, normalDisposition);
|
|
Assert.Equal(liveVelocity, normal.Physics!.Value.Velocity);
|
|
Assert.False(normalTimestamps.TeleportAdvanced);
|
|
|
|
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 AcceptedPhysicsTimestamps teleportTimestamps));
|
|
Assert.Equal(PositionTimestampDisposition.Apply, teleportDisposition);
|
|
Assert.Equal(Vector3.Zero, teleported.Physics!.Value.Velocity);
|
|
Assert.True(teleportTimestamps.TeleportAdvanced);
|
|
}
|
|
|
|
[Fact]
|
|
public void ParentEventWaitsForParent_ThenStagesTimestampUntilValidatedCommit()
|
|
{
|
|
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 staged));
|
|
Assert.Null(staged.ParentGuid);
|
|
Assert.NotNull(staged.Position);
|
|
Assert.Equal((ushort)5, staged.PositionSequence);
|
|
Assert.True(controller.TryCommitParent(
|
|
update.ChildGuid,
|
|
update.ParentGuid,
|
|
update.ParentLocation,
|
|
update.PlacementId,
|
|
update.ChildPositionSequence,
|
|
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 StaleMovementTimestampLeavesLegacySnapshotByteIdentical()
|
|
{
|
|
var controller = new InboundPhysicsStateController();
|
|
WorldSession.EntitySpawn spawn = WithTimestamps(
|
|
Spawn(0x70000008u, 3, 1, 1, Position(0x0101FFFFu, 10f), 0x408u),
|
|
movement: 5,
|
|
serverControl: 5);
|
|
controller.AcceptCreate(spawn);
|
|
Assert.True(controller.TryGetSnapshot(spawn.Guid, out WorldSession.EntitySpawn before));
|
|
|
|
// MOVEMENT_TS 3 is OLDER than the stored 5:
|
|
// PhysicsTimestampGate.TryAcceptMovementEvent's
|
|
// AdvanceStrict(Movement, ...) fails before ever consulting
|
|
// SERVER_CONTROLLED_MOVE_TS - the gate is entirely untouched, and
|
|
// the canonical snapshot must not move either (the regression this
|
|
// guards: ApplyAcceptedMotion stamping the WIRE's own stale
|
|
// MovementSequence instead of the gate's unchanged post-call value).
|
|
bool applied = controller.TryApplyMotion(
|
|
new WorldSession.EntityMotionUpdate(
|
|
spawn.Guid,
|
|
new CreateObject.ServerMotionState(0x3d, 0x99),
|
|
InstanceSequence: 3,
|
|
MovementSequence: 3,
|
|
ServerControlSequence: 9,
|
|
IsAutonomous: false),
|
|
retainPayload: true,
|
|
out WorldSession.EntitySpawn accepted,
|
|
out _);
|
|
|
|
Assert.False(applied);
|
|
Assert.Equal(default, accepted);
|
|
Assert.True(controller.TryGetSnapshot(spawn.Guid, out WorldSession.EntitySpawn after));
|
|
Assert.Equal(before, after);
|
|
}
|
|
|
|
[Fact]
|
|
public void InstanceMismatchedMovementLeavesLegacySnapshotByteIdentical()
|
|
{
|
|
var controller = new InboundPhysicsStateController();
|
|
WorldSession.EntitySpawn spawn = WithTimestamps(
|
|
Spawn(0x70000009u, 3, 1, 1, Position(0x0101FFFFu, 10f), 0x408u),
|
|
movement: 1,
|
|
serverControl: 1);
|
|
controller.AcceptCreate(spawn);
|
|
Assert.True(controller.TryGetSnapshot(spawn.Guid, out WorldSession.EntitySpawn before));
|
|
|
|
// A different INSTANCE_TS: TryAcceptInstance fails immediately,
|
|
// before MOVEMENT_TS or SERVER_CONTROLLED_MOVE_TS are ever
|
|
// consulted. Nothing in the gate or the canonical snapshot may move
|
|
// - a stale duplicate or foreign-incarnation packet must not
|
|
// silently drag a live entity's movement timestamps forward.
|
|
bool applied = controller.TryApplyMotion(
|
|
new WorldSession.EntityMotionUpdate(
|
|
spawn.Guid,
|
|
new CreateObject.ServerMotionState(0x3d, 0x99),
|
|
InstanceSequence: 4,
|
|
MovementSequence: 9,
|
|
ServerControlSequence: 9,
|
|
IsAutonomous: false),
|
|
retainPayload: true,
|
|
out WorldSession.EntitySpawn accepted,
|
|
out _);
|
|
|
|
Assert.False(applied);
|
|
Assert.Equal(default, accepted);
|
|
Assert.True(controller.TryGetSnapshot(spawn.Guid, out WorldSession.EntitySpawn after));
|
|
Assert.Equal(before, after);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// #307: the live Position path must report the TELEPORT_TS value the gate
|
|
/// held BEFORE the event, not 0. Every consumer of
|
|
/// <c>AcceptedPhysicsTimestamps.PreviousTeleport</c> compares it against
|
|
/// the accepted stamp; a hard 0 makes "this packet did not advance
|
|
/// TELEPORT_TS" indistinguishable from "this packet regressed it" for any
|
|
/// entity that has ever teleported.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TryApplyPosition_ReportsThePreEventTeleportStamp()
|
|
{
|
|
var controller = new InboundPhysicsStateController();
|
|
WorldSession.EntitySpawn spawn = WithTimestamps(
|
|
Spawn(0x50000010u, 3, 10, 1, Position(0x0101FFFFu, 10f), 0x408u),
|
|
teleport: 10,
|
|
forcePosition: 0);
|
|
controller.AcceptCreate(spawn);
|
|
|
|
// An ordinary Position carrying the SAME TELEPORT_TS: previous and
|
|
// accepted must both be the live stamp, and nothing advanced.
|
|
Assert.True(controller.TryApplyPosition(
|
|
PositionUpdate(spawn.Guid, instance: 3, position: 11, teleport: 10),
|
|
isLocalPlayer: false,
|
|
forcePositionRotation: null,
|
|
currentLocalVelocity: null,
|
|
out PositionTimestampDisposition steady,
|
|
out _,
|
|
out AcceptedPhysicsTimestamps steadyStamps));
|
|
Assert.Equal(PositionTimestampDisposition.Apply, steady);
|
|
Assert.Equal((ushort)10, steadyStamps.PreviousTeleport);
|
|
Assert.Equal((ushort)10, steadyStamps.Teleport);
|
|
Assert.False(steadyStamps.TeleportAdvanced);
|
|
|
|
// A fresh TELEPORT_TS: previous is the PRE-event stamp, accepted is
|
|
// the new one. Capturing after the gate mutated would collapse both
|
|
// onto 11 and silently lose the advance.
|
|
Assert.True(controller.TryApplyPosition(
|
|
PositionUpdate(spawn.Guid, instance: 3, position: 12, teleport: 11),
|
|
isLocalPlayer: false,
|
|
forcePositionRotation: null,
|
|
currentLocalVelocity: null,
|
|
out PositionTimestampDisposition advanced,
|
|
out _,
|
|
out AcceptedPhysicsTimestamps advancedStamps));
|
|
Assert.Equal(PositionTimestampDisposition.Apply, advanced);
|
|
Assert.Equal((ushort)10, advancedStamps.PreviousTeleport);
|
|
Assert.Equal((ushort)11, advancedStamps.Teleport);
|
|
Assert.True(advancedStamps.TeleportAdvanced);
|
|
}
|
|
|
|
/// <summary>
|
|
/// #307, the shipped consequence: a local player who has portalled or
|
|
/// recalled this session holds a nonzero TELEPORT_TS. Retail's
|
|
/// FORCE_POSITION branch (<c>SmartBox::HandleReceivedPosition</c>
|
|
/// 0x00453FD0) fires only when the packet's teleport stamp EQUALS the live
|
|
/// one and never advances it, so the authority C4 route 2 builds from
|
|
/// these timestamps must satisfy
|
|
/// <c>PreviousTeleportSequence == AcceptedTeleportSequence</c>. With
|
|
/// PreviousTeleport pinned to 0 the classifier rejected the authority and
|
|
/// the force correction was silently dropped.
|
|
/// </summary>
|
|
[Fact]
|
|
public void LocalPlayerForcePositionAfterATeleport_ClassifiesAsAnAcceptedForceCorrection()
|
|
{
|
|
var controller = new InboundPhysicsStateController();
|
|
WorldSession.EntitySpawn spawn = WithTimestamps(
|
|
Spawn(0x50000011u, 3, 10, 1, Position(0x0101FFFFu, 10f), 0x408u),
|
|
teleport: 10,
|
|
forcePosition: 0);
|
|
controller.AcceptCreate(spawn);
|
|
|
|
Assert.True(controller.TryApplyPosition(
|
|
PositionUpdate(
|
|
spawn.Guid,
|
|
instance: 3,
|
|
position: 9,
|
|
teleport: 10,
|
|
forcePosition: 1),
|
|
isLocalPlayer: true,
|
|
forcePositionRotation: Quaternion.Identity,
|
|
currentLocalVelocity: Vector3.Zero,
|
|
out PositionTimestampDisposition disposition,
|
|
out WorldSession.EntitySpawn accepted,
|
|
out AcceptedPhysicsTimestamps timestamps));
|
|
Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition);
|
|
|
|
// Built exactly as the route-2 drive builds it from these outputs.
|
|
var authority = new RuntimeAuthoritativePositionAuthority(
|
|
new RuntimeGenerationToken(7),
|
|
new RuntimeEntityKey(spawn.Guid, 1),
|
|
PositionAuthorityVersion: 4UL,
|
|
AcceptedPositionSequence: 9,
|
|
timestamps.PreviousTeleport,
|
|
timestamps.Teleport,
|
|
disposition);
|
|
RuntimeAuthoritativePositionRoute route =
|
|
RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition(
|
|
new RuntimeAcceptedPositionRouteRequest(
|
|
authority,
|
|
RuntimePositionEntityKind.LocalPlayer,
|
|
RuntimeAcceptedPositionSource.PositionEvent,
|
|
accepted.Position!.Value,
|
|
PlacementFrame: 0u,
|
|
PositionPackVelocity: Vector3.Zero,
|
|
CommittedCellId: 0x0101FFFFu,
|
|
HasContact: true,
|
|
PlayerDistance: 0f,
|
|
UsePositionFromServer: true,
|
|
HasAnimations: false,
|
|
default));
|
|
|
|
Assert.Equal(
|
|
RuntimeAuthoritativePositionDisposition.SetPositionSimple,
|
|
route.Disposition);
|
|
Assert.True(route.SendPositionImmediately);
|
|
Assert.Equal((ushort)10, timestamps.PreviousTeleport);
|
|
Assert.Equal((ushort)10, timestamps.Teleport);
|
|
}
|
|
|
|
private static WorldSession.EntityPositionUpdate PositionUpdate(
|
|
uint guid,
|
|
ushort instance,
|
|
ushort position,
|
|
ushort teleport,
|
|
ushort forcePosition = 0) =>
|
|
new(
|
|
guid,
|
|
Position(0x0101FFFFu, 20f),
|
|
null,
|
|
null,
|
|
true,
|
|
instance,
|
|
position,
|
|
teleport,
|
|
forcePosition);
|
|
|
|
/// <summary>
|
|
/// C5b review L4 (2026-08-05). The merge's HasAnimations proxy is
|
|
/// <c>old.MotionTableId ?? old.Physics?.MotionTableId</c> — a coalesce
|
|
/// whose whole point (per the production comment on
|
|
/// <c>RuntimeAcceptedPositionRouteRequests.Build</c>) is that
|
|
/// WeenieDescription and ObjDesc merges populate only ONE of the two
|
|
/// fields, depending on which stage last touched appearance vs
|
|
/// description. Every pre-existing fixture set both halves to the same
|
|
/// value, so deleting either operand of the <c>??</c> was undetectable
|
|
/// while the production comment claimed the mixed case is the real-world
|
|
/// one. These rows are the mixed cases, and the oracle is still
|
|
/// <c>Build</c> + the production classifier, so the two independent
|
|
/// copies of the coalesce stay pinned equal on exactly the inputs that
|
|
/// discriminate between them.
|
|
///
|
|
/// <para>The explicit-zero row is the third discriminator: a present-but-
|
|
/// zero top half is NOT null, so <c>??</c> never reaches the nonzero
|
|
/// physics half and the entity is correctly unanimated. Swapping the
|
|
/// coalesce operands would flip that row alone.</para>
|
|
/// </summary>
|
|
[Theory]
|
|
// topLevelMotionTableId, physicsMotionTableId, expectedAnimated
|
|
[InlineData(null, 0x09000001u, true)]
|
|
[InlineData(0x09000001u, null, true)]
|
|
[InlineData(0x09000001u, 0x09000002u, true)]
|
|
[InlineData(null, null, false)]
|
|
[InlineData(0u, 0x09000001u, false)]
|
|
[InlineData(null, 0u, false)]
|
|
public void MixedMotionTableSourcesDriveTheSamePlacementFrameGateAsTheRoute(
|
|
uint? topLevelMotionTableId,
|
|
uint? physicsMotionTableId,
|
|
bool expectedAnimated)
|
|
{
|
|
const uint guid = 0x80000041u;
|
|
WorldSession.EntitySpawn seed = WithTimestamps(
|
|
Spawn(guid, 3, 10, 1, Position(0x0101FFFFu, 10f), 0x408u),
|
|
teleport: 10,
|
|
forcePosition: 0);
|
|
seed = seed with
|
|
{
|
|
PlacementId = 7,
|
|
MotionTableId = topLevelMotionTableId,
|
|
Physics = seed.Physics!.Value with
|
|
{
|
|
AnimationFrame = 7,
|
|
MotionTableId = physicsMotionTableId,
|
|
},
|
|
};
|
|
|
|
WorldSession.EntityPositionUpdate update = new(
|
|
guid,
|
|
Position(0x0101FFFFu, 20f),
|
|
Velocity: null,
|
|
PlacementId: 5,
|
|
IsGrounded: true,
|
|
InstanceSequence: 3,
|
|
PositionSequence: 11,
|
|
TeleportSequence: 10,
|
|
ForcePositionSequence: 0);
|
|
|
|
var merging = new InboundPhysicsStateController();
|
|
merging.AcceptCreate(seed);
|
|
Assert.True(merging.TryApplyPosition(
|
|
update,
|
|
isLocalPlayer: false,
|
|
forcePositionRotation: null,
|
|
currentLocalVelocity: null,
|
|
out PositionTimestampDisposition disposition,
|
|
out WorldSession.EntitySpawn merged,
|
|
out AcceptedPhysicsTimestamps timestamps));
|
|
Assert.Equal(PositionTimestampDisposition.Apply, disposition);
|
|
|
|
using RuntimeEntityObjectLifetime oracle = OracleLifetime();
|
|
RuntimeEntityRecord canonical = RegisterOracleSnapshot(oracle, seed);
|
|
RuntimeAuthoritativePositionRoute route =
|
|
RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition(
|
|
RuntimeAcceptedPositionRouteRequests.Build(
|
|
new RuntimeGenerationToken(7),
|
|
canonical,
|
|
new RuntimeEntityKey(guid, 1),
|
|
update,
|
|
RuntimePositionEntityKind.Remote,
|
|
RuntimeAcceptedPositionSource.PositionEvent,
|
|
disposition,
|
|
timestamps.PreviousTeleport,
|
|
timestamps.Teleport,
|
|
playerDistance: 0f,
|
|
usePositionFromServer: true,
|
|
committedCellId: 0x0101FFFFu));
|
|
|
|
// The classified route's own gate agrees with the fixture's intent…
|
|
Assert.Equal(!expectedAnimated, route.ApplyPlacementFrameBeforeRouting);
|
|
// …and the merge, which computed its own copy, lands the SAME frame.
|
|
// Animated => retail's HasAnims gate @0x00454137 skips SetPlacementFrame
|
|
// entirely, so the seed's 7 survives; unanimated => the wire's 5 lands.
|
|
Assert.Equal(expectedAnimated ? 7u : 5u, merged.PlacementId);
|
|
Assert.Equal(
|
|
expectedAnimated ? 7u : 5u,
|
|
merged.Physics!.Value.AnimationFrame);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A minimal Runtime lifetime whose only purpose is to own a canonical
|
|
/// <see cref="RuntimeEntityRecord"/> so the production
|
|
/// <c>RuntimeAcceptedPositionRouteRequests.Build</c> can be the oracle
|
|
/// (C5b review L3). The fixture landblock matches the 0x0101FFFF cells
|
|
/// every packet in this file uses.
|
|
/// </summary>
|
|
private static RuntimeEntityObjectLifetime OracleLifetime()
|
|
{
|
|
var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
|
|
engine.AddLandblock(
|
|
0x01010000u,
|
|
new TerrainSurface(new byte[81], new float[256]),
|
|
Array.Empty<CellSurface>(),
|
|
Array.Empty<PortalPlane>(),
|
|
worldOffsetX: 0f,
|
|
worldOffsetY: 0f);
|
|
return new RuntimeEntityObjectLifetime(engine);
|
|
}
|
|
|
|
private static RuntimeEntityRecord RegisterOracleSnapshot(
|
|
RuntimeEntityObjectLifetime lifetime,
|
|
WorldSession.EntitySpawn seed)
|
|
{
|
|
RuntimeEntityRecord canonical =
|
|
lifetime.RegisterEntity(seed).Canonical!;
|
|
// The pin is only worth anything if the canonical snapshot really is
|
|
// the same spawn the merge retained.
|
|
Assert.Equal(seed.MotionTableId, canonical.Snapshot.MotionTableId);
|
|
Assert.Equal(
|
|
seed.Physics!.Value.MotionTableId,
|
|
canonical.Snapshot.Physics!.Value.MotionTableId);
|
|
return canonical;
|
|
}
|
|
|
|
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 },
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// C5b (#275): <paramref name="motionTableId"/> drives AP-130's static
|
|
/// HasAnimations proxy, which now gates the steady-state merge's
|
|
/// placement-frame install exactly as retail's
|
|
/// <c>CPhysicsObj::HasAnims</c> check @0x00454137 gates
|
|
/// <c>SetPlacementFrame</c>. The default keeps every pre-existing fixture
|
|
/// animated, which is what the retail population overwhelmingly is.
|
|
/// </summary>
|
|
private static WorldSession.EntitySpawn Spawn(
|
|
uint guid,
|
|
ushort instance,
|
|
ushort positionSequence,
|
|
ushort stateSequence,
|
|
CreateObject.ServerPosition? position,
|
|
uint state,
|
|
uint? motionTableId = 0x09000001u)
|
|
{
|
|
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: motionTableId,
|
|
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,
|
|
motionTableId,
|
|
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);
|
|
}
|