fix(physics): classify before merge on every steady-state Position (C5b, #275, AP-131/AD-60)
The steady-state accepted-Position merge did two things retail never does,
on every single Position packet: it installed the wire placement frame and
unparented unconditionally, and it derived the record's FullCellId from
bare wire acceptance. Both are now correct, and they land together - a
half-flipped intermediate (classified flags with the wire stamp, or vice
versa) is exactly the mixed-residency state this campaign keeps paying for.
WHY the flags need no route. SmartBox::HandleReceivedPosition @0x00453FD0
decides both pre-placement writes BEFORE MoveOrTeleport is consulted: Gate A
@0x0045400C returns @0x0045409D ahead of unset_parent @0x00454129 and ahead
of the HasAnims SetPlacementFrame gate @0x00454137. Neither gate reads the
near/far/teleport classification. So the two flags are a pure function of
(disposition, hasAnimations) and are computable inside the merge, pre-merge,
with no signature change, no route construction and no playerDistance - the
scoping's ~150-400-line route-plumbing estimate over-counted because it did
not see this. That truth table IS
RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition's own
ApplyPlacementFrameBeforeRouting/UnparentBeforeRouting rows; the classifier
stays the oracle and the equality is pinned by test, not by a shared path,
so each computation remains separately sabotage-verifiable.
WHY the cell is withheld. HandleReceivedPosition reads the wire objcell_id
into a LOCAL @0x00453FE3 and hands it only to BlipPlayer / TeleportPlayer /
MoveOrTeleport / ConstrainTo; it never assigns the object's cell. The
object's cell moves inside the placement family (SetPositionInternal
@0x00515BD0 to set_cell, enter_world) or per-frame transit, and nowhere
else. The continuation executor has encoded that rule since the executor
slice; this caller now matches it verbatim.
WHAT DELIBERATELY SURVIVES. Two steady-state wire-cell writers stay,
downstream of the merge and outside the classification window: the
OnPosition prologue rebucket (W2, into CommitRebucket), which is also the
local player's own cell-freshness path, and the post-routing wire-cell adopt
for non-placing arms (W3, AP-135). Gating W2 "for symmetry" would freeze the
player's canonical cell between teleports and #319's child-cell equality
would inherit the freeze. AD-60's rewrite names both so the retirement
cannot be misread as "wire acceptance never changes residency anywhere".
REGISTER. AP-131 RETIRED - the unconditional literals no longer exist; the
caller was corrected, not deleted, so the row's own "deleted at the
production cutover" framing is overtaken. AD-60's legacy half RETIRED and
the row REWRITTEN rather than deleted, naming W2/W3 (route 4b-3's D8
precedent: a silent whole-row deletion would hide surviving channels).
AP-130 amended - the merge consumes the same static HasAnimations proxy,
deliberately not escalated to a live animation-queue read. AP-146 and #320
amended - their "accepted inbound Position (RefreshSnapshot into
RuntimeEntityRecord.cs:234)" local-player cell writer is now the generic
tail's CommitRebucket, and a ForcePosition (which returns before that tail)
is placement-receipt-authoritative. #275 closed.
HEADLINE BEHAVIOURAL DELTA, stated once: a refused or contended local
ForcePosition now leaves FullCellId at the last committed cell where the
merge used to stamp the refused packet's wire cell. Retail cannot refuse
(AD-62) and its body keeps its last placed cell, so the new shape is the
retail-reachable one.
THREE CONSUMER SITES THE CONTRACT'S BLAST-RADIUS SURVEY MISSED, all
D2-caused, all found by the suite rather than by reading, all intended
semantics rather than regressions (recorded in the contract's new section
14):
(1) DatLiveEntityProjectionMaterializer's self-projection branch reads
FullCellId inside OnPosition's prologue recovery, ahead of W2. It now
correctly declines to project from an unplaced wire claim; production
installs the bucket at W2 in the same call (verified: no return between
the recovery call and W2 is conditioned on IsSpatiallyProjected or
FullCellId). Two hydration tests asserted the bucket at the recovery
boundary and now drive the production W2 step - the same shape as trap
T2, one layer up.
(2) ProjectileController.SyncPresentationFromResolvedBody writes
ParentCellId = record.FullCellId. On a refused missile placement that is
now the committed source cell. The MAJOR-1 invariant is unchanged and is
now asserted as the identity it always meant rather than as a wire-cell
constant.
(3) The merge's Rebucketed ternary does NOT become always-Updated as the
contract predicted, and is deliberately kept: the
Forget(restoreCancelledPark: true) above it can roll a wakeable
lost-cell park back, and RestoreParkWithdrawal restores canonical
residency. That is a real cell edge produced inside this method by a
placement owner.
TEST-COUNT RECONCILIATION. Baseline measured at this HEAD by stashing the
change: Runtime.Tests 1176, App.Tests 4135 (4132 passed / 3 skipped),
solution 11,106 passed / 4 skipped - matching the recorded figure at
6921a027 exactly. Post-change: Runtime.Tests 1195, App.Tests 4135 unchanged,
solution 11,125 passed / 4 skipped / 0 failed. Net +19, entirely new Runtime
tests: 3 facts plus a 12-row matrix theory in
InboundPhysicsStateControllerTests, 1 fact plus a 2-row theory in the new
RuntimeSteadyStatePositionMergeTests, and 1 fact in
RuntimeAcceptedPositionDriveControllerTests. No test was deleted; five
existing tests were rewritten in place, never delete-only. No new skip; none
of #302/#308/#321 appeared.
SABOTAGE VERIFICATIONS (each new discriminating test, both directions;
production line broken, suite run, line restored):
installPlacementFrame (!force && !hasAnimations) to (!force)
5 fail: ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame plus
the 4 animated non-force matrix rows.
installPlacementFrame to false
6 fail: ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame,
PositionPlacementAbsentAndPresentZeroBothApplyRetailZero plus the 4
non-animated non-force matrix rows.
clearParent (!force) to true
3 fail: ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment
plus the 2 force+parented matrix rows.
clearParent (!force) to false
4 fail: the 4 Apply+parented matrix rows.
refreshPosition false to acceptedPosition
4 fail: AcceptedPosition_WithholdsTheWireCellAtTheMergeBoundary,
ContendedForcePosition_WritesNoResidencyAnywhere,
ReentrantNewerPositionDuringPickupDiscardSuppressesStalePickupDelta,
MissileFarRefused_...ParentCellIdAgreesWithCommittedCell. Confirmed a
second time by the baseline measurement above, where the withhold test
was the sole red.
CommitRebucket publishes Updated instead of Rebucketed
2 fail: both parent classes of
CellChangingAcceptedPosition_ConservesOneRebucketAndOneChildPropagation.
RuntimeEntityDirectory.SetFullCell drops PropagateFullCellToChildren
2 fail: the same two rows.
T4 respected: the ForcePosition placement-frame half is inert
(appliedPlacement keeps old.PlacementId under either flag value), so the
force row's discriminating assertion is parent retention, never the frame.
NOT DONE, deliberately: the executor is still not wired into the
steady-state path (#275's alternative branch); W2/W3 are untouched; no probe
added or stripped; AP-130's proxy not escalated; no while-here unification
of the two merge callsites. No automated OnPosition-level test drives the
full pickup / drop / reproject sequence (no fixture covers pickup at that
layer); the contract's connected gate recipe item 1 is the positive evidence
for it and has NOT been run - this commit is not connected-gated.
Contract: docs/research/2026-08-05-c5b-contract.md (committed here, with its
section 14 implementation outcome appended).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0257844106
commit
735f0a72af
11 changed files with 1715 additions and 63 deletions
|
|
@ -66,12 +66,28 @@ public sealed class InboundPhysicsStateControllerTests
|
|||
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);
|
||||
0x70000009u,
|
||||
7,
|
||||
20,
|
||||
1,
|
||||
Position(0x0101FFFFu, 10f),
|
||||
0x408u,
|
||||
motionTableId: null);
|
||||
seed = seed with
|
||||
{
|
||||
PlacementId = 7,
|
||||
|
|
@ -122,6 +138,306 @@ public sealed class InboundPhysicsStateControllerTests
|
|||
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.
|
||||
RuntimeAuthoritativePositionRoute route =
|
||||
RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition(
|
||||
new RuntimeAcceptedPositionRouteRequest(
|
||||
new RuntimeAuthoritativePositionAuthority(
|
||||
new RuntimeGenerationToken(7),
|
||||
new RuntimeEntityKey(guid, 1),
|
||||
PositionAuthorityVersion: 4UL,
|
||||
AcceptedPositionSequence: update.PositionSequence,
|
||||
timestamps.PreviousTeleport,
|
||||
timestamps.Teleport,
|
||||
disposition),
|
||||
isLocalPlayer
|
||||
? RuntimePositionEntityKind.LocalPlayer
|
||||
: RuntimePositionEntityKind.Remote,
|
||||
RuntimeAcceptedPositionSource.PositionEvent,
|
||||
update.Position,
|
||||
PlacementFrame: update.PlacementId,
|
||||
PositionPackVelocity: null,
|
||||
CommittedCellId: 0x0101FFFFu,
|
||||
HasContact: true,
|
||||
PlayerDistance: 0f,
|
||||
UsePositionFromServer: true,
|
||||
HasAnimations: animated,
|
||||
default));
|
||||
|
||||
// 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()
|
||||
{
|
||||
|
|
@ -645,13 +961,22 @@ public sealed class InboundPhysicsStateControllerTests
|
|||
};
|
||||
}
|
||||
|
||||
/// <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 state,
|
||||
uint? motionTableId = 0x09000001u)
|
||||
{
|
||||
var timestamps = new PhysicsTimestamps(
|
||||
positionSequence,
|
||||
|
|
@ -669,7 +994,7 @@ public sealed class InboundPhysicsStateControllerTests
|
|||
Movement: null,
|
||||
AnimationFrame: null,
|
||||
SetupTableId: 0x02000001u,
|
||||
MotionTableId: 0x09000001u,
|
||||
MotionTableId: motionTableId,
|
||||
SoundTableId: null,
|
||||
PhysicsScriptTableId: null,
|
||||
Parent: null,
|
||||
|
|
@ -696,7 +1021,7 @@ public sealed class InboundPhysicsStateControllerTests
|
|||
"fixture",
|
||||
null,
|
||||
null,
|
||||
0x09000001u,
|
||||
motionTableId,
|
||||
PhysicsState: state,
|
||||
InstanceSequence: instance,
|
||||
MovementSequence: 1,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,297 @@
|
|||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Entities;
|
||||
|
||||
namespace AcDream.Runtime.Tests.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// C5b (#275) — the steady-state accepted-Position merge is no longer a
|
||||
/// residency writer. Retires AD-60's legacy half.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail's <c>SmartBox::HandleReceivedPosition</c> @0x00453FD0 reads the
|
||||
/// wire <c>objcell_id</c> into a LOCAL @0x00453FE3 and hands it only to
|
||||
/// <c>BlipPlayer</c>/<c>TeleportPlayer</c>/<c>MoveOrTeleport</c>/
|
||||
/// <c>ConstrainTo</c>; it never assigns the object's own cell. The object's
|
||||
/// cell moves inside the placement family
|
||||
/// (<c>SetPositionInternal</c> @0x00515BD0 -> <c>set_cell</c>,
|
||||
/// <c>enter_world</c>) or per-frame movement transit, and nowhere else.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// TRAP T2: the withhold assertion belongs at THIS boundary
|
||||
/// (<see cref="RuntimeEntityObjectLifetime.TryApplyPosition"/>), never at
|
||||
/// <c>LiveEntityNetworkUpdateController.OnPosition</c> level — the OnPosition
|
||||
/// prologue rebucket legitimately commits the wire cell a few statements
|
||||
/// later (W2, the local player's own cell-freshness path, AP-146/#320), and
|
||||
/// an OnPosition-level assertion would fail and tempt a weakening.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class RuntimeSteadyStatePositionMergeTests
|
||||
{
|
||||
private const uint Landblock = 0xA9C60000u;
|
||||
private const uint LandblockSentinel = Landblock | 0xFFFFu;
|
||||
private const uint Cell = Landblock | 0x0001u;
|
||||
private const uint OtherCell = Landblock | 0x0002u;
|
||||
|
||||
/// <summary>
|
||||
/// C5b proof obligation 2. The pose half of the merge still lands; only
|
||||
/// the derived <c>FullCellId</c> write is withheld, and a later canonical
|
||||
/// commit still moves it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AcceptedPosition_WithholdsTheWireCellAtTheMergeBoundary()
|
||||
{
|
||||
using RuntimeEntityObjectLifetime lifetime = EngineLifetime();
|
||||
Bind(lifetime);
|
||||
const uint guid = 0x80005001u;
|
||||
RuntimeEntityRecord canonical =
|
||||
lifetime.RegisterEntity(Spawn(guid, 1)).Canonical!;
|
||||
Assert.Equal(Cell, canonical.FullCellId);
|
||||
|
||||
Assert.True(lifetime.TryApplyPosition(
|
||||
PositionUpdate(guid, OtherCell, positionSequence: 2),
|
||||
isLocalPlayer: false,
|
||||
forcePositionRotation: null,
|
||||
currentLocalVelocity: null,
|
||||
acknowledgeProjection: null,
|
||||
out PositionTimestampDisposition disposition,
|
||||
out _,
|
||||
out AcceptedPhysicsTimestamps timestamps));
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, disposition);
|
||||
|
||||
// THE withhold: an accepted wire cell that differs from the committed
|
||||
// cell leaves the record exactly where the last commit put it.
|
||||
Assert.Equal(Cell, canonical.FullCellId);
|
||||
Assert.NotEqual(OtherCell, canonical.FullCellId);
|
||||
// 4b-3's D1 threading now equals a structural property of the record
|
||||
// through the whole classification window.
|
||||
Assert.Equal(Cell, timestamps.PreMergeCommittedCellId);
|
||||
|
||||
// The POSE half of the merge is emphatically not withheld.
|
||||
Assert.Equal(
|
||||
OtherCell,
|
||||
canonical.Snapshot.Position!.Value.LandblockId);
|
||||
Assert.Equal(30f, canonical.Snapshot.Position!.Value.PositionX);
|
||||
Assert.Equal((ushort)2, canonical.Snapshot.PositionSequence);
|
||||
|
||||
// A canonical commit — the only kind of writer retail allows — still
|
||||
// changes residency.
|
||||
Assert.True(lifetime.CommitRebucket(
|
||||
canonical,
|
||||
OtherCell,
|
||||
LandblockSentinel));
|
||||
Assert.Equal(OtherCell, canonical.FullCellId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C5b proof obligation 3 — publish-delta conservation across the moved
|
||||
/// edge (trap T3). Pre-C5b the merge itself published the
|
||||
/// <c>Rebucketed</c> delta and propagated to children, and the prologue
|
||||
/// rebucket then early-returned publish-less because the cell already
|
||||
/// matched. Post-C5b the merge publishes <c>Updated</c> and the prologue
|
||||
/// rebucket's <c>CommitRebucket</c> publishes the <c>Rebucketed</c>:
|
||||
/// same kind, same value, same publisher, a later point in the same call.
|
||||
/// Exactly ONE of each, never zero and never two — pinned at the
|
||||
/// observable, not at the source site.
|
||||
///
|
||||
/// <para>Both parent classes per the #319 discipline: a test population
|
||||
/// that only ever sees creature parents is blind to the player class.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(0x50005101u, 0x50005102u)]
|
||||
[InlineData(0x80005201u, 0x80005202u)]
|
||||
public void CellChangingAcceptedPosition_ConservesOneRebucketAndOneChildPropagation(
|
||||
uint parentGuid,
|
||||
uint childGuid)
|
||||
{
|
||||
using RuntimeEntityObjectLifetime lifetime = EngineLifetime();
|
||||
Bind(lifetime);
|
||||
RuntimeEntityRecord parent =
|
||||
lifetime.RegisterEntity(Spawn(parentGuid, 1)).Canonical!;
|
||||
lifetime.RegisterEntity(Spawn(childGuid, 1, includePosition: false));
|
||||
Assert.True(CommitAttachment(lifetime, parentGuid, 1, childGuid, 2));
|
||||
Assert.True(lifetime.Entities.TryGetActive(
|
||||
childGuid, out RuntimeEntityRecord child));
|
||||
Assert.Equal(Cell, child.FullCellId);
|
||||
|
||||
ulong childSpatialBefore = child.SpatialAuthorityVersion;
|
||||
var deltas = new List<RuntimeEntityDelta>();
|
||||
using IDisposable subscription = lifetime.Events.Subscribe(
|
||||
new RecordingEntityObserver(deltas));
|
||||
|
||||
// The production steady-state sequence, in production order: the
|
||||
// merge, then the OnPosition prologue rebucket (W2) at the wire cell.
|
||||
Assert.True(lifetime.TryApplyPosition(
|
||||
PositionUpdate(parentGuid, OtherCell, positionSequence: 3),
|
||||
isLocalPlayer: false,
|
||||
forcePositionRotation: null,
|
||||
currentLocalVelocity: null,
|
||||
acknowledgeProjection: null,
|
||||
out PositionTimestampDisposition disposition,
|
||||
out _,
|
||||
out _));
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, disposition);
|
||||
Assert.True(lifetime.CommitRebucket(parent, OtherCell, LandblockSentinel));
|
||||
|
||||
Assert.Equal(OtherCell, parent.FullCellId);
|
||||
RuntimeEntityDelta rebucketed = Assert.Single(
|
||||
deltas,
|
||||
d => d.Change is RuntimeEntityChange.Rebucketed
|
||||
&& d.Entity.Identity.ServerGuid == parentGuid);
|
||||
Assert.Equal(OtherCell, rebucketed.Entity.CellId);
|
||||
|
||||
// #319's child-equality channel: exactly one propagation, and the
|
||||
// child lands on the parent's new cell.
|
||||
Assert.Equal(OtherCell, child.FullCellId);
|
||||
Assert.Equal(childSpatialBefore + 1UL, child.SpatialAuthorityVersion);
|
||||
}
|
||||
|
||||
private sealed class RecordingEntityObserver(List<RuntimeEntityDelta> destination)
|
||||
: IRuntimeEntityObjectObserver
|
||||
{
|
||||
public void OnEntity(in RuntimeEntityDelta delta) => destination.Add(delta);
|
||||
|
||||
public void OnInventory(in RuntimeInventoryDelta delta)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static RuntimeEntityObjectLifetime EngineLifetime()
|
||||
{
|
||||
var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
|
||||
engine.AddLandblock(
|
||||
Landblock,
|
||||
new TerrainSurface(new byte[81], new float[256]),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
return new RuntimeEntityObjectLifetime(engine);
|
||||
}
|
||||
|
||||
private static void Bind(RuntimeEntityObjectLifetime lifetime)
|
||||
{
|
||||
var token = new RuntimeGenerationToken(1UL);
|
||||
lifetime.BindEventContext(() => token, static () => 1UL);
|
||||
}
|
||||
|
||||
private static bool CommitAttachment(
|
||||
RuntimeEntityObjectLifetime lifetime,
|
||||
uint parentGuid,
|
||||
ushort parentInstance,
|
||||
uint childGuid,
|
||||
ushort childPositionSequence)
|
||||
{
|
||||
var relation = new ParentAttachmentRelation(
|
||||
parentGuid,
|
||||
childGuid,
|
||||
ParentLocation: 0u,
|
||||
PlacementId: 0u,
|
||||
parentInstance,
|
||||
childPositionSequence);
|
||||
lifetime.Entities.ParentAttachments.AcceptCreateObjectRelation(relation);
|
||||
var update = new ParentEvent.Parsed(
|
||||
parentGuid,
|
||||
childGuid,
|
||||
0u,
|
||||
0u,
|
||||
parentInstance,
|
||||
childPositionSequence);
|
||||
if (!lifetime.TryApplyParent(update, acknowledgeProjection: null, out _))
|
||||
return false;
|
||||
if (!lifetime.TryCommitParent(relation, acknowledgeProjection: null, out _))
|
||||
return false;
|
||||
if (!lifetime.Entities.ParentAttachments.CommitProjection(relation))
|
||||
return false;
|
||||
if (!lifetime.Entities.TryGetActive(
|
||||
childGuid,
|
||||
out RuntimeEntityRecord canonical))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return lifetime.CommitAcceptedParentCellless(
|
||||
canonical,
|
||||
canonical.PositionAuthorityVersion,
|
||||
acknowledgeProjection: null);
|
||||
}
|
||||
|
||||
private static WorldSession.EntityPositionUpdate PositionUpdate(
|
||||
uint guid,
|
||||
uint cell,
|
||||
ushort positionSequence) =>
|
||||
new(
|
||||
guid,
|
||||
new CreateObject.ServerPosition(cell, 30f, 20f, 7f, 1f, 0f, 0f, 0f),
|
||||
Velocity: null,
|
||||
PlacementId: null,
|
||||
IsGrounded: true,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: positionSequence,
|
||||
TeleportSequence: 0,
|
||||
ForcePositionSequence: 0);
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(
|
||||
uint guid,
|
||||
ushort incarnation,
|
||||
bool includePosition = true)
|
||||
{
|
||||
CreateObject.ServerPosition? position = includePosition
|
||||
? new CreateObject.ServerPosition(Cell, 10f, 20f, 7f, 1f, 0f, 0f, 0f)
|
||||
: null;
|
||||
var timestamps = new PhysicsTimestamps(
|
||||
Position: 1,
|
||||
Movement: 1,
|
||||
State: 1,
|
||||
Vector: 1,
|
||||
Teleport: 0,
|
||||
ServerControlledMove: 1,
|
||||
ForcePosition: 0,
|
||||
ObjDesc: 1,
|
||||
Instance: incarnation);
|
||||
var physics = new PhysicsSpawnData(
|
||||
RawState: (uint)PhysicsStateFlags.Gravity,
|
||||
Position: position,
|
||||
Movement: null,
|
||||
AnimationFrame: null,
|
||||
SetupTableId: null,
|
||||
MotionTableId: null,
|
||||
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,
|
||||
SetupTableId: null,
|
||||
Array.Empty<CreateObject.AnimPartChange>(),
|
||||
Array.Empty<CreateObject.TextureChange>(),
|
||||
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
null,
|
||||
null,
|
||||
"fixture",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
PhysicsState: (uint)PhysicsStateFlags.Gravity,
|
||||
InstanceSequence: incarnation,
|
||||
MovementSequence: 1,
|
||||
ServerControlSequence: 1,
|
||||
PositionSequence: 1,
|
||||
Physics: physics);
|
||||
}
|
||||
}
|
||||
|
|
@ -1787,7 +1787,16 @@ public sealed class RuntimeSetPositionStateTests
|
|||
|
||||
Assert.True(reentered);
|
||||
RuntimeEntityDelta only = Assert.Single(entities.Deltas);
|
||||
Assert.Equal(RuntimeEntityChange.Rebucketed, only.Change);
|
||||
// C5b (#275): the reentrant accepted Position publishes Updated, not
|
||||
// Rebucketed — the steady-state merge is no longer a residency writer,
|
||||
// so a wire cell arriving on a withdrawn (picked-up) record does not
|
||||
// make it resident. Production commits the cell one step later, at the
|
||||
// OnPosition prologue rebucket, which this Runtime-level fixture does
|
||||
// not drive. The SUBJECT of this test is unchanged: exactly one delta,
|
||||
// published by the reentrant Position, superseding the stale pickup
|
||||
// discard.
|
||||
Assert.Equal(RuntimeEntityChange.Updated, only.Change);
|
||||
Assert.Equal(0u, record.FullCellId);
|
||||
Assert.True(only.Stamp.Sequence > discardStamp.Sequence);
|
||||
Assert.False(lifetime.Physics.SetPosition.AcknowledgeProjection(
|
||||
pending.Projection));
|
||||
|
|
|
|||
|
|
@ -255,6 +255,81 @@ public sealed class RuntimeAcceptedPositionDriveControllerTests
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C5b (#275) proof obligation 4 — the assertion that makes AD-60's
|
||||
/// legacy-half retirement mean something. A local ForcePosition whose
|
||||
/// drive execution CONTENDS performs no placement, so nothing may write
|
||||
/// residency for that packet: the merge no longer stamps the wire cell
|
||||
/// (C5b), there is no placement receipt, and the OnPosition generic tail
|
||||
/// (W2) is never reached because the force path returns before it.
|
||||
/// <c>FullCellId</c> is the pre-packet committed cell throughout.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail cannot refuse at all (AD-62), and its body simply keeps the
|
||||
/// last cell <c>set_cell</c> gave it — so the surviving shape here is the
|
||||
/// retail-reachable one. Before C5b the merge stamped the refused
|
||||
/// packet's wire cell, which is the AD-60 legacy-half divergence.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ContendedForcePosition_WritesNoResidencyAnywhere()
|
||||
{
|
||||
using StartedRuntime started = StartRuntime();
|
||||
GameRuntime runtime = started.Runtime;
|
||||
(RuntimeEntityRecord record, PlayerMovementController controller) =
|
||||
EnterLocalPlayer(runtime);
|
||||
uint committedCellBefore = record.FullCellId;
|
||||
Assert.NotEqual(0u, committedCellBefore);
|
||||
|
||||
// A wire cell that is deliberately NOT the committed one, so the
|
||||
// pre-C5b stamp would have been observable.
|
||||
const uint wireCell = SpawnLandblock | 0x0002u;
|
||||
Assert.NotEqual(wireCell, committedCellBefore);
|
||||
WorldSession.EntityPositionUpdate wire = ForceUpdate(
|
||||
new Vector3(30f, 32f, SpawnHeight),
|
||||
landblockId: wireCell);
|
||||
(PositionTimestampDisposition disposition, AcceptedPhysicsTimestamps timestamps) =
|
||||
MergeAccepted(runtime, controller, wire);
|
||||
Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition);
|
||||
|
||||
// (1) The merge itself wrote no residency.
|
||||
Assert.Equal(committedCellBefore, record.FullCellId);
|
||||
|
||||
RuntimeEntityPlacementToken displaced = runtime.EntityObjects.Physics
|
||||
.SetPosition.TryBeginExclusiveAuthoredPlacement(
|
||||
record,
|
||||
record.PositionAuthorityVersion,
|
||||
RuntimeSetPositionOperationKind.LocalAuthoritative);
|
||||
Assert.True(displaced.IsValid);
|
||||
RuntimeAcceptedPositionDriveController drive =
|
||||
CreateAcceptedPositionDrive(runtime, out List<byte[]> gameActions);
|
||||
|
||||
Assert.Equal(
|
||||
RuntimeAcceptedPositionExecutionStatus.Contention,
|
||||
drive.TryExecuteAcceptedLocalPosition(
|
||||
record,
|
||||
wire,
|
||||
disposition,
|
||||
timestamps,
|
||||
timestamps.PreviousTeleport));
|
||||
|
||||
// (2) No receipt, so no residency change from the execution either.
|
||||
Assert.Equal(committedCellBefore, record.FullCellId);
|
||||
Assert.Empty(gameActions);
|
||||
|
||||
// (3) And nothing rebounds it on the settle.
|
||||
drive.Advance();
|
||||
Assert.Equal(committedCellBefore, record.FullCellId);
|
||||
|
||||
RuntimePlacementCancellationReceipt cancellation = runtime.EntityObjects
|
||||
.Physics.SetPosition.ForgetExactPlacement(displaced);
|
||||
if (cancellation.IsValid)
|
||||
{
|
||||
runtime.EntityObjects.Physics.SetPosition
|
||||
.PublishCancellation(cancellation);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeferredCell_ParksThenCommitsAndNeverDoubleAcksAfterTheCollisionGenerationWakes()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue