fix(physics): C4 route 3 — portal placement authority (local player)
Removes a duplicate placement authority for local-player portal arrival. Portalling worked before this change and works after it — this is not a bug fix, EXCEPT that it found and fixed one dead-code production bug. THE PRODUCTION BUG: TryExecuteCanonicalPortalPlacement re-read the accepted destination at Place time, but TryBeginPortalReveal already consumes that slot at Aim time — so the arm was 100% dead code and every real portal Place refused with host-token-unavailable. Found only because we refused to accept 7 skipped tests instead of chasing the count to zero. RETAIL IS THE GENERIC PATH FOR THE THIRD ROUTE RUNNING: SmartBox::TeleportPlayer @0x00453910 = SetPositionSimple(dest, 1) with flags 0x1012, followed by PlayerPositionUpdated. BOTH INVERSIONS, WITH THEIR ANCHORS: unlike route 2, the leash IS armed here (ConstrainTo @0x0045418A) and velocity is zeroed (set_velocity @0x004541B4); unlike route 4b-3, the local teleport_hook runs AFTER placement (@0x004538AE). THE THREE-ROUND DEFECT CHAIN, HONESTLY: - Round 1 released the player at the pre-teleport position while the anim stream marched on — the contract wrongly assumed Place re-fires (process rule 1's third occurrence this campaign). - Round 2's fix inferred commit from a global PendingCount, which three non-committing paths also clear — making the SAME bug complete cleanly and silently. Strictly worse than round 1: round 1 at least tripped portal-complete-before-materialized. - Round 3 latches the commit where it actually happens (ReconcileAndAcknowledgePortal), keyed on reveal generation and teleport sequence, via TryConsumePortalCommit. Two of the three required regression tests landed and are sabotage-verified on both hosts (ParkedPlace_ForgottenByOrdinaryMergeDoesNotLatchAsCommitted / HeadlessPortalPrepareDestinationForgottenByOrdinaryMergeDoesNotLatchAsCommitted). The third (force-arm-takes-the-slot) was judged unnecessary on review: with the inference gone, PendingCount is only a "don't ask yet" guard at both gates, so a force operation occupying or vacating the slot no longer changes an input the commit decision reads — the case collapses into what the landed test already discriminates. THE B2/P3 RESOLUTION: both round-2 reviews were right about different branches of the same synchronous call. RuntimePlacementProjectionSubscription .OnPlacement acknowledges the FIFO head only when TryApply returns true; a Place whose portal authority went stale (transit ended/superseded while parked) used to return false, wedging every later entity's placement receipt behind it forever. Both sinks (RuntimePlacementPresentationSink, HeadlessRuntimePlacementProjectionSink) now acknowledge-and-ignore a stale-authority Place instead of refusing it. The regression test (RuntimePlacementPresentationSinkTests .PortalPlace_StaleTransitHostOrSequenceIsAcknowledgedAndIgnored) had been asserting the old, wrong `false` behaviour; it now asserts and sabotage-verifies the fix. Also lands: AP-144 (register discipline — the portal movement-event send reuses the stricter UsePositionFromServer gate where retail's SendMovementEvent is the looser autonomy_level != 0 test, diverging only at level 1, currently unreachable), AP-145 + issue #318 (the local-player collision-shadow presentation write bypasses its own publisher's ShadowObjects write via a direct cache .Set(), self-healing only once dedup diverges — filed, not fixed, pending a composition test), AD-42 deleted (its last citation retired by the canonical portal arm), AD-2 updated (the wait-cue's trigger predicate now covers a second cause), and two documentation corrections: the enter_world misattribution (both call sites are in SmartBox::HandleCreateObject, only one in the player branch — portal arrival is TeleportPlayer, not enter_world) and the stale "local player never reaches this path" comment on the generic-remote-render-pose write. Suite: 11,090 passed / 4 skipped / 0 failed. No new skips, nothing weakened. STILL OWED: the connected two-client gate, with ACDREAM_PROBE_LOCAL_TELEPORT=1, scored only if [local-tp] lines actually appear in the capture — and explicitly NOT scored as covering issue #318 (no composition test yet asserts PhysicsEngine.ShadowObjects directly). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
cd3129e9d6
commit
e0f96a55bf
24 changed files with 5261 additions and 243 deletions
|
|
@ -1949,6 +1949,145 @@ public sealed class PlayerMovementController
|
|||
UpdateCellId(_body.CellPosition.ObjCellId, "force-position");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 3: the controller-local half of a portal-teleport commit
|
||||
/// whose body write, cell install, and orientation already happened
|
||||
/// inside Runtime's canonical <c>RuntimeSetPositionState.CommitCanonical</c>
|
||||
/// (retail <c>CPhysicsObj::SetPositionSimple</c> @0x005162B0 with flags
|
||||
/// <c>0x1012</c>, called from <c>SmartBox::TeleportPlayer</c> @0x00453910,
|
||||
/// acclient_2013_pseudo_c.txt:284276/92528). Unlike
|
||||
/// <see cref="CommitCanonicalForcePositionFrame"/> (which the FORCE_POSITION
|
||||
/// branch's early return at @0x0045409D exempts from every
|
||||
/// <c>ConstrainTo</c>), the local TELEPORT branch of
|
||||
/// <c>SmartBox::HandleReceivedPosition</c> (@0x0045415F) DOES re-arm the
|
||||
/// leash (@0x0045418A, anchored at the received destination) and DOES
|
||||
/// zero velocity (@0x004541B4) — the inversion is deliberate, not a
|
||||
/// missed exemption; see docs/research/2026-08-04-c4-route-3-contract.md
|
||||
/// §2 Inversion A.
|
||||
///
|
||||
/// Performs every <see cref="SetPositionCore"/> duty NOT already covered
|
||||
/// by the canonical commit (P1's duty map): render-lerp anchor reset,
|
||||
/// <c>UpdateCellId</c> publication, the retail teleport_hook tail
|
||||
/// (UnStick @0x00514eee / UnConstrain @0x00514f02 / re-arm @0x0045418A),
|
||||
/// the retail StopCompletely full stop (0x00527e40, zeroes velocity and
|
||||
/// resets fwd/sidestep/turn commands so input resumes at rest), the
|
||||
/// input-edge/mouse press-edge reset, and the physics-clock reset for a
|
||||
/// fresh <c>update_object</c> boundary. TransientState (Contact/OnWalkable/
|
||||
/// Sliding/WaterContact) is deliberately NOT re-seeded here — the canonical
|
||||
/// commit's <c>PhysicsObjUpdate.CommitSetPositionContactTransition</c>
|
||||
/// already derives those bits from the SLIDE placement's OWN resolved
|
||||
/// contact result, which is more retail-faithful than the old
|
||||
/// <see cref="SetPositionCore"/>'s unconditional
|
||||
/// <c>Contact|OnWalkable|Active</c> overwrite (that overwrite could mark a
|
||||
/// portal arrival grounded even when the destination placement actually
|
||||
/// resolved airborne). <c>Active</c> is untouched because a live in-world
|
||||
/// local player already carries it; the canonical commit only sets it on
|
||||
/// entry from a celless residence, which a portal arrival never is.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// A4 review fix (2026-08-05): the two inversions this method embodies
|
||||
/// are named, retail-cited facts on the classifier's
|
||||
/// <c>RuntimeAuthoritativePositionRoute</c> — <c>ZeroVelocity</c> and
|
||||
/// <c>ConstrainPhase.AfterPositionOperation</c> — and this method must
|
||||
/// actually READ them rather than assume the LocalPlayer-teleport
|
||||
/// branch's values are the only ones that will ever reach it. Both
|
||||
/// parameters are the route's own facts, passed by the one caller
|
||||
/// (<c>RuntimeAcceptedPositionDriveController.ReconcileAndAcknowledgePortal</c>);
|
||||
/// a future classifier edit that changes either value now changes this
|
||||
/// method's behaviour instead of silently disagreeing with it.
|
||||
/// <para>
|
||||
/// Coordinator note (round-3 closeout, 2026-08-05): the two parameters
|
||||
/// are read, not hardcoded — but they are NOT equally load-bearing.
|
||||
/// <paramref name="zeroVelocity"/> is read and applied, then
|
||||
/// <see cref="StopCompletelyAtPhysicsObjectBoundary"/> runs
|
||||
/// UNCONDITIONALLY on the very next line and zeroes velocity again — so
|
||||
/// a <c>zeroVelocity: false</c> sabotage changes nothing observable
|
||||
/// here; the field is proven read but not proven DISCRIMINATING.
|
||||
/// <paramref name="rearmConstraintLeash"/> (<c>ConstrainAfterRouting</c>)
|
||||
/// has no such unconditional fallback and IS the load-bearing one —
|
||||
/// it alone decides whether the leash re-arms. Do not read this doc
|
||||
/// comment as proving both fields equally; only the leash flag is.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="runTeleportHookTail">
|
||||
/// N4 review fix (2026-08-05): before this parameter, the caller gated
|
||||
/// the ENTIRE method call on <c>route.RunsTeleportHook</c> — but retail's
|
||||
/// <c>SetPositionInternal</c> @0x00515330 does the frame/cell/stop/input-
|
||||
/// reset/clock work UNCONDITIONALLY; only retail's <c>teleport_hook</c>
|
||||
/// @0x00514ED0 (UnStick/UnConstrain/re-arm, mapped below) is itself
|
||||
/// conditional on the hook phase. Gating the whole call meant a future
|
||||
/// <see cref="AcDream.Runtime.Physics.RuntimeAuthoritativePositionRoute.TeleportHookPhase"/>
|
||||
/// of <c>None</c> would silently skip the render-root <c>UpdateCellId</c>
|
||||
/// publish too — the doorway-FLAP class. Today the portal route always
|
||||
/// sets a non-None phase, so this parameter is always <c>true</c> in
|
||||
/// production and there is no live behavior change; it exists so a
|
||||
/// future <c>None</c> phase changes only the hook tail, not the frame
|
||||
/// commit.
|
||||
/// </param>
|
||||
internal void CommitCanonicalTeleportFrame(
|
||||
bool zeroVelocity,
|
||||
bool rearmConstraintLeash,
|
||||
bool runTeleportHookTail = true)
|
||||
{
|
||||
EnsurePublishedForRuntimeOperation();
|
||||
_prevPhysicsPos = _body.Position;
|
||||
_currPhysicsPos = _body.Position;
|
||||
UpdateCellId(_body.CellPosition.ObjCellId, "teleport");
|
||||
|
||||
// Retail set_velocity(player, 0, 1) @0x004541B4 — route.ZeroVelocity.
|
||||
if (zeroVelocity)
|
||||
_body.Velocity = Vector3.Zero;
|
||||
// Retail teleport idle is a FULL stop (StopCompletely 0x00527e40):
|
||||
// resets fwd/sidestep/turn COMMANDS and zeroes velocity again so the
|
||||
// motion interpreter cannot reconstruct the pre-teleport run vector
|
||||
// the instant input resumes.
|
||||
StopCompletelyAtPhysicsObjectBoundary();
|
||||
_activeInputTurnCommand = null;
|
||||
_activeInputTurnSpeed = 0f;
|
||||
_activeInputTurnFromMouse = false;
|
||||
_activeInputSidestepCommand = null;
|
||||
_activeInputSidestepUsesRunHold = false;
|
||||
_mouseLookActive = false;
|
||||
_mouseTurnSamplePending = false;
|
||||
_mouseTurnAdjustment = 0f;
|
||||
_mouseMovementEventCandidate = false;
|
||||
_mouseMovementEventPending = false;
|
||||
|
||||
// Retail teleport_hook @0x00514ed0 tears down any active stick/leash
|
||||
// unconditionally, then HandleReceivedPosition's TELEPORT branch
|
||||
// immediately re-arms the leash anchored to the just-committed
|
||||
// position ONLY when ConstrainPhase is AfterPositionOperation
|
||||
// (Inversion A — the opposite of
|
||||
// CommitCanonicalForcePositionFrame's no-re-arm rule, itself
|
||||
// route.ConstrainPhase.None for FORCE_POSITION). N4 review fix: this
|
||||
// is the ONLY part of this method retail actually conditions on the
|
||||
// teleport-hook phase — everything above runs unconditionally.
|
||||
if (runTeleportHookTail)
|
||||
{
|
||||
PositionManager?.UnStick();
|
||||
PositionManager?.UnConstrain();
|
||||
if (rearmConstraintLeash)
|
||||
RearmConstraintLeashAtCurrentPosition();
|
||||
}
|
||||
|
||||
// Reset the edge tracker: the stop wiped the motion state, so keys
|
||||
// still physically held must re-fire as press edges on the next
|
||||
// Update (matches SetPositionCore's walking-straight-out-of-a-
|
||||
// teleport behavior while W stays held).
|
||||
_prevForwardHeld = false;
|
||||
_prevBackwardHeld = false;
|
||||
_prevStrafeLeftHeld = false;
|
||||
_prevStrafeRightHeld = false;
|
||||
_prevTurnLeftHeld = false;
|
||||
_prevTurnRightHeld = false;
|
||||
_prevRunHeld = false;
|
||||
_hasInputSnapshot = false;
|
||||
|
||||
// Reset physics clock so any subsequent update_object calls start fresh.
|
||||
_body.LastUpdateTime = 0.0;
|
||||
_objectClock.ResetForEnterWorld();
|
||||
}
|
||||
|
||||
private Vector3 ComputeRenderPosition()
|
||||
{
|
||||
float alpha = Math.Clamp(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue