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:
Erik 2026-08-05 21:17:20 +02:00
parent 0257844106
commit 735f0a72af
11 changed files with 1715 additions and 63 deletions

View file

@ -593,17 +593,23 @@ public sealed class InboundPhysicsStateController
/// position payload is rejected. This lets callers publish a freshly
/// consumed FORCE_POSITION_TS without applying a stale pose.
///
/// Round 4 R4-15: this legacy immediate-apply path has no HasContact or
/// route-classification concept at all - it merges unconditionally on
/// the retained <see cref="PositionTimestampDisposition"/> alone. The
/// continuation executor's <c>ApplyPositionAction</c> instead runs
/// <c>RuntimeAuthoritativePositionRouteClassifier</c> and derives
/// contact solely from the retained wire packet's own
/// <c>IsGrounded</c> bit. This is internal refactor debt tracked for
/// the eventual cutover unification (this file's <c>TryApplyPosition</c>
/// is today's only PRODUCTION Position wire caller; the classifier-based
/// path is test-only until a host wires the executor) - it is NOT a
/// retail divergence and does not belong in
/// C5b (#275): this steady-state merge no longer installs retail's two
/// PRE-PLACEMENT writes unconditionally. Retail runs both of them BEFORE
/// <c>MoveOrTeleport</c> is consulted, so their gates are a pure function
/// of the timestamp disposition and the static HasAnims proxy - which is
/// why classifying them here needs no route, no player distance and no
/// signature change (see the truth table at the call below). The
/// near/far/teleport routing decision proper is still downstream of this
/// merge and still belongs to
/// <c>RuntimeAuthoritativePositionRouteClassifier</c>, which the
/// continuation executor's <c>ApplyPositionAction</c> runs directly and
/// which the App-layer OnPosition tail runs post-merge for this caller;
/// contact still comes solely from the retained wire packet's own
/// <c>IsGrounded</c> bit on both. That remaining structural difference
/// (two callers computing the same two flags from the same two inputs
/// rather than sharing one code path) is internal refactor debt tracked
/// for the eventual cutover unification - it is NOT a retail divergence
/// and does not belong in
/// docs/architecture/retail-divergence-register.md. See docs/ISSUES.md
/// for the tracked follow-up.
/// </summary>
@ -646,6 +652,46 @@ public sealed class InboundPhysicsStateController
teleportAdvanced: disposition is PositionTimestampDisposition.Apply
&& advancesTeleport,
previousTeleport: previousTeleport);
// C5b (#275) — CLASSIFY BEFORE MERGE. Retires AP-131.
//
// SmartBox::HandleReceivedPosition (0x00453FD0) decides both
// pre-placement writes before it ever consults MoveOrTeleport:
// - GATE A @0x0045400C (the local-player FORCE_POSITION self-echo
// shortcut) returns @0x0045409D BEFORE CPhysicsObj::unset_parent
// @0x00454129 and before the SetPlacementFrame gate @0x00454137.
// A ForcePosition therefore neither unparents nor installs a
// placement frame.
// - unset_parent @0x00454129 is UNCONDITIONAL on every other
// accepted path: retail unparents before it learns the routing
// outcome, so `clearParent` needs no route.
// - SetPlacementFrame @0x00454142 is gated on
// CPhysicsObj::HasAnims(arg2) == 0 @0x00454137 and on nothing
// else.
// Because neither gate reads the near/far/teleport classification,
// the two flags are a pure function of (disposition, hasAnimations)
// and are computable here, pre-merge, with no route plumbing:
//
// disposition | installPlacementFrame | clearParent
// --------------+-----------------------+------------
// Rejected | unread (timestamp-only branch)
// ForcePosition | false | false
// Apply | !hasAnimations | true
//
// That is exactly RuntimeAuthoritativePositionRouteClassifier's own
// ApplyPlacementFrameBeforeRouting/UnparentBeforeRouting rows
// (ClassifyAcceptedPosition: false/false on the force row, and
// `!request.HasAnimations`/true on EVERY accepted non-force route).
// The classifier stays the oracle; the equality of the two small pure
// computations is pinned by test, not by a shared code path, so each
// remains separately sabotage-verifiable.
bool force = disposition is PositionTimestampDisposition.ForcePosition;
// AP-130's static proxy, computed from the PRE-merge snapshot `old`
// with the identical expression RuntimeAcceptedPositionRouteRequests
// uses. Deliberately NOT a live animation-queue read.
bool hasAnimations =
(old.MotionTableId ?? old.Physics?.MotionTableId)
is { } motionTableId
&& motionTableId != 0u;
accepted = ApplyAcceptedPosition(
old,
update,
@ -654,13 +700,8 @@ public sealed class InboundPhysicsStateController
isLocalPlayer,
forcePositionRotation,
currentLocalVelocity,
// Legacy immediate-apply reproduces EXACT prior behavior: the
// placement frame and parent clear were always unconditional
// here (see the Round 3 A1/B6 admission handoff). Only the
// continuation executor threads the classified route's own
// ApplyPlacementFrameBeforeRouting/UnparentBeforeRouting flags.
installPlacementFrame: true,
clearParent: true);
installPlacementFrame: !force && !hasAnimations,
clearParent: !force);
_snapshots[update.Guid] = accepted;
return true;
}
@ -669,7 +710,10 @@ public sealed class InboundPhysicsStateController
/// the remarks on <see cref="ApplyAcceptedObjDescSnapshot"/>. The
/// executor passes its classified route's own
/// <c>ApplyPlacementFrameBeforeRouting</c>/<c>UnparentBeforeRouting</c>
/// flags rather than the legacy path's unconditional true/true.</summary>
/// flags; since C5b (#275) the steady-state
/// <see cref="TryApplyPosition"/> caller passes the same two values,
/// derived pre-merge from (disposition, hasAnimations) rather than read
/// off a route.</summary>
internal bool ApplyAcceptedPositionSnapshot(
uint guid,
WorldSession.EntityPositionUpdate update,
@ -770,14 +814,16 @@ public sealed class InboundPhysicsStateController
/// <see cref="AcceptedPhysicsTimestamps"/> captured at admission time.
///
/// <paramref name="installPlacementFrame"/>/<paramref name="clearParent"/>
/// (Round 3 B6) let the two callers reproduce two different retail
/// gates: the legacy immediate-apply path always passes true/true
/// (retail's HandleReceivedPosition unconditionally runs
/// unset_parent/SetPlacementFrame there), while the continuation
/// executor passes its classified route's own
/// <c>ApplyPlacementFrameBeforeRouting</c>/<c>UnparentBeforeRouting</c> -
/// both false only for the FORCE_POSITION branch, which retail's
/// MoveOrTeleport returns from immediately, BEFORE either call.
/// (Round 3 B6) carry retail's two PRE-PLACEMENT gates. Since C5b (#275)
/// BOTH callers supply the same classified values, from the same two
/// inputs: the continuation executor reads its classified route's
/// <c>ApplyPlacementFrameBeforeRouting</c>/<c>UnparentBeforeRouting</c>,
/// and the steady-state <see cref="TryApplyPosition"/> merge derives them
/// pre-merge from (disposition, hasAnimations) - see the truth table
/// there. Both are false only for the FORCE_POSITION branch, which
/// retail's HandleReceivedPosition Gate A returns from immediately,
/// BEFORE either call; <paramref name="installPlacementFrame"/> is
/// additionally false whenever HasAnims is true.
/// </summary>
internal static WorldSession.EntitySpawn ApplyAcceptedPosition(
WorldSession.EntitySpawn old,
@ -810,8 +856,11 @@ public sealed class InboundPhysicsStateController
// id to zero; HandleReceivedPosition (0x00453FD0) forwards that exact
// value to SetPlacementFrame on a normal accepted update - but only
// when the caller's route actually runs that step
// (installPlacementFrame; retail skips it entirely while HasAnimations
// is true).
// (installPlacementFrame; retail's @0x00454137 gate skips
// SetPlacementFrame entirely while HasAnims is true, and Gate A
// returns before reaching it at all). Since C5b (#275) that is live
// for BOTH callers: an animated entity's accepted Position no longer
// stamps the wire frame onto the steady-state merge either.
uint? appliedPlacement = installPlacementFrame
? (disposition is PositionTimestampDisposition.Apply
? update.PlacementId ?? 0u

View file

@ -1893,14 +1893,22 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
disposition is not PositionTimestampDisposition.Rejected;
// C4 route 4b-3 (D1): the classifier's remote cell-less predicate
// needs the PRE-merge committed cell — the value this method just
// measured as `beforeCell`, before `RefreshSnapshot`
// below stamps the accepted wire cell onto the canonical record via
// `RefreshDerivedState` -> `SetFullCell`. Reading `canonical.FullCellId`
// AFTER that merge (as the classifier's default builder overload
// does for every other caller) always sees the wire cell, which is
// why the predicate as fed to a remote PositionEvent was dead before
// this fix. `hadCanonical` is what makes this an honest value rather
// than a fabricated 0 — see the field's own doc.
// measured as `beforeCell`. Before C5b `RefreshSnapshot` below
// stamped the accepted wire cell onto the canonical record via
// `RefreshDerivedState` -> `SetFullCell`, so reading
// `canonical.FullCellId` AFTER the merge (as the classifier's default
// builder overload does for every other caller) always saw the wire
// cell, which is why the predicate as fed to a remote PositionEvent
// was dead before this fix. `hadCanonical` is what makes this an
// honest value rather than a fabricated 0 — see the field's own doc.
//
// C5b (#275) does NOT retire this threading. The merge no longer
// writes the cell (see the CANONICAL CELL SEMANTICS note below), so
// the post-merge record now CARRIES this same value through the whole
// classification window — the threaded value became a
// belt-and-suspenders duplicate of a structural property. It stays,
// and it stays the classifier's input of record: one observable, read
// once, at the point it is measured.
timestamps = timestamps with
{
PreMergeCommittedCellId = hadCanonical ? beforeCell : null,
@ -1920,10 +1928,34 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
canonical,
restoreCancelledPark: true);
}
// CANONICAL CELL SEMANTICS (C5b, #275 — retires AD-60's legacy half;
// this caller now matches the continuation executor's long-standing
// rule verbatim): refreshPosition stays false. A wire position never
// directly makes the record resident; only a Runtime SetPosition
// commit or a simulation full-cell commit may change FullCellId.
// This is retail: SmartBox::HandleReceivedPosition (0x00453FD0) reads
// the wire objcell_id into a LOCAL @0x00453FE3 and hands it to
// BlipPlayer/TeleportPlayer/MoveOrTeleport/ConstrainTo; it never
// assigns the object's own cell. The object's cell moves only inside
// the placement family (SetPositionInternal @0x00515BD0 -> set_cell,
// enter_world) or per-frame movement transit.
//
// The snapshot's Position field itself IS still refreshed; only the
// derived FullCellId write is withheld.
//
// Two wire-cell writers deliberately SURVIVE this change, downstream
// of the merge and outside the classification window: the OnPosition
// prologue rebucket (LiveEntityNetworkUpdateController ->
// LiveEntityRuntime.RebucketLiveEntity -> CommitRebucket, which is
// also the local player's own cell-freshness path, AP-146/#320) and
// the post-routing wire-cell adopt for non-placing arms
// (TryAdoptWireCellAfterRouting, filed at AP-135). Neither is gated
// here: gating the prologue rebucket would freeze the local player's
// canonical cell between teleports.
Entities.RefreshSnapshot(
canonical,
snapshot,
refreshPosition: acceptedPosition);
refreshPosition: false);
if (acceptedPosition
&& ReferenceEquals(canonical, beforeCanonical))
{
@ -1942,6 +1974,16 @@ public sealed class RuntimeEntityObjectLifetime : IDisposable
&& canonical.SpatialAuthorityVersion == spatialVersion);
}
// C5b (#275): the MERGE can no longer move the cell, so a wire cell
// change publishes its Rebucketed delta at the OnPosition prologue
// rebucket's CommitRebucket instead — same kind, same value, same
// publisher, a later point in the same call. This comparison is NOT
// dead, and is deliberately kept rather than collapsed to Updated:
// the cancellation above (Forget with restoreCancelledPark) can roll
// a wakeable lost-cell park back, and RestoreParkWithdrawal restores
// canonical residency at the body's committed cell. That is a real
// cell edge produced inside this method by a placement owner, and it
// must still publish as Rebucketed.
return AcknowledgeProjectionAndPublish(
canonical,
() => acknowledgeProjection?.Invoke(canonical),