acdream/tests/AcDream.Runtime.Tests/Entities/RuntimeSteadyStatePositionMergeTests.cs
Erik 735f0a72af 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>
2026-08-05 21:17:44 +02:00

297 lines
11 KiB
C#

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