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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue