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:
Erik 2026-08-05 03:57:37 +02:00
parent cd3129e9d6
commit e0f96a55bf
24 changed files with 5261 additions and 243 deletions

View file

@ -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(