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
|
|
@ -10,6 +10,8 @@ using AcDream.Core.Physics;
|
|||
using AcDream.Core.Rendering;
|
||||
using AcDream.Core.World;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Physics;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.Runtime.World;
|
||||
|
||||
namespace AcDream.App.Streaming;
|
||||
|
|
@ -173,68 +175,67 @@ internal sealed class LocalPlayerTeleportStreamingOperations
|
|||
|
||||
internal interface ILocalPlayerTeleportPlacement
|
||||
{
|
||||
void Place(Vector3 position, uint cellId, Quaternion rotation);
|
||||
void Place(Quaternion rotation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commits the local player's deferred portal arrival. It owns the exact
|
||||
/// Place -> root/controller/camera mutation -> spatial reconcile edge.
|
||||
/// C4 route 3: acknowledges the local player's deferred portal arrival —
|
||||
/// the canonical placement itself now runs through
|
||||
/// <see cref="RuntimeAcceptedPositionDriveController.TryExecuteAcceptedPortalArrival"/>
|
||||
/// (a portal arm sharing route 2's Runtime SetPosition owner), retiring the
|
||||
/// duplicate Resolve/SetPosition authority this class used to own (D1;
|
||||
/// docs/research/2026-08-04-c4-route-3-contract.md D-T4). This class runs
|
||||
/// AFTER that commit succeeds. A10 review fix (2026-08-05): the render
|
||||
/// <see cref="WorldEntity"/> pose write and rebucket this method performs
|
||||
/// are REDUNDANT repeats of a mutation the canonical Place receipt already
|
||||
/// made — <c>RuntimePlacementPresentationSink.TryApply</c> →
|
||||
/// <c>LiveEntityRuntime.TryApplyRuntimePlacementPlace</c> already calls
|
||||
/// <c>entity.SetPosition</c>/sets <c>Rotation</c>/<c>ParentCellId</c> and
|
||||
/// rebuckets, synchronously, before <c>TryPublishPlace</c>'s OWN snapshot
|
||||
/// even runs (proof obligation P2's ordering). This method's writes are
|
||||
/// therefore harmless-but-duplicate, not the render entity's ONLY mover as
|
||||
/// an earlier revision of this comment claimed; kept because they cost
|
||||
/// nothing extra and this is also where the retail teleport_hook tail's
|
||||
/// remaining local-player-visible actions run (target-watcher
|
||||
/// notification, camera reset, spatial reconcile).
|
||||
/// <paramref name="rotation"/> is the retained accepted destination's wire
|
||||
/// orientation — the resolved body orientation was already committed
|
||||
/// identically by <c>CommitCanonical</c> (retail's teleport branch does not
|
||||
/// independently reorient the mover), so re-deriving it here would only add
|
||||
/// a second copy of the same source of truth.
|
||||
/// </summary>
|
||||
internal sealed class LocalPlayerTeleportPlacement : ILocalPlayerTeleportPlacement
|
||||
{
|
||||
private readonly PhysicsEngine _physics;
|
||||
private readonly LiveEntityRuntime _liveEntities;
|
||||
private readonly ILocalPlayerIdentitySource _identity;
|
||||
private readonly IRuntimeLocalPlayerControllerSource _controller;
|
||||
private readonly ILocalPlayerPhysicsHostSource _host;
|
||||
private readonly ChaseCameraInputState _cameras;
|
||||
private readonly LiveWorldOriginState _origin;
|
||||
private readonly ILiveSpatialReconcilePhase _spatial;
|
||||
|
||||
public LocalPlayerTeleportPlacement(
|
||||
PhysicsEngine physics,
|
||||
LiveEntityRuntime liveEntities,
|
||||
ILocalPlayerIdentitySource identity,
|
||||
IRuntimeLocalPlayerControllerSource controller,
|
||||
ILocalPlayerPhysicsHostSource host,
|
||||
ChaseCameraInputState cameras,
|
||||
LiveWorldOriginState origin,
|
||||
ILiveSpatialReconcilePhase spatial)
|
||||
{
|
||||
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
|
||||
_controller = controller ?? throw new ArgumentNullException(nameof(controller));
|
||||
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
_cameras = cameras ?? throw new ArgumentNullException(nameof(cameras));
|
||||
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
|
||||
_spatial = spatial ?? throw new ArgumentNullException(nameof(spatial));
|
||||
}
|
||||
|
||||
public void Place(Vector3 position, uint cellId, Quaternion rotation)
|
||||
public void Place(Quaternion rotation)
|
||||
{
|
||||
PlayerMovementController controller = _controller.Controller
|
||||
?? throw new InvalidOperationException(
|
||||
"Teleport Place ran without the local player controller.");
|
||||
var resolved = _physics.Resolve(
|
||||
position,
|
||||
cellId,
|
||||
Vector3.Zero,
|
||||
controller.StepUpHeight);
|
||||
var snapped = new Vector3(
|
||||
resolved.Position.X,
|
||||
resolved.Position.Y,
|
||||
resolved.Position.Z);
|
||||
|
||||
uint playerGuid = _identity.ServerGuid;
|
||||
controller.SetPosition(
|
||||
snapped,
|
||||
resolved.CellId,
|
||||
CellLocalForSeed(snapped, resolved.CellId));
|
||||
|
||||
// SnapToCell owns the retail Position frame and may normalize an
|
||||
// outdoor land-cell index from the cell-local origin. Publish that
|
||||
// canonical result, not the pre-snap resolver hint, to rendering.
|
||||
if (_liveEntities.TryGetWorldEntity(
|
||||
playerGuid,
|
||||
out WorldEntity? entity))
|
||||
|
|
@ -245,12 +246,12 @@ internal sealed class LocalPlayerTeleportPlacement : ILocalPlayerTeleportPlaceme
|
|||
|
||||
// Retail CPhysicsObj::enter_world installs the object in its
|
||||
// destination CObjCell before hidden scripts/particles resume.
|
||||
// The accepted Position packet has already advanced FullCellId,
|
||||
// but that wire fact alone does not move acdream's retained
|
||||
// projection out of its source/pending GPU bucket. Commit both
|
||||
// halves of the spatial move here, while portal space still owns
|
||||
// the viewport, so CPhysicsObj::update_object's cell-gated tail
|
||||
// can advance the Hidden/UnHide PES chain at retail's boundary.
|
||||
// The canonical commit has already advanced FullCellId, but that
|
||||
// fact alone does not move acdream's retained projection out of
|
||||
// its source/pending GPU bucket. Commit both halves of the
|
||||
// spatial move here, while portal space still owns the
|
||||
// viewport, so CPhysicsObj::update_object's cell-gated tail can
|
||||
// advance the Hidden/UnHide PES chain at retail's boundary.
|
||||
if (!_liveEntities.RebucketLiveEntity(playerGuid, controller.CellId))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
|
|
@ -260,9 +261,13 @@ internal sealed class LocalPlayerTeleportPlacement : ILocalPlayerTeleportPlaceme
|
|||
}
|
||||
|
||||
// Retail teleport_hook tail @ 0x00514ED0 clears the local target and
|
||||
// notifies every watcher that this object teleported.
|
||||
// notifies every watcher that this object teleported. The body's
|
||||
// constraint leash re-arm and orientation are already the canonical
|
||||
// commit's job (RuntimeAcceptedPositionDriveController
|
||||
// .ReconcileAndAcknowledgePortal -> PlayerMovementController
|
||||
// .CommitCanonicalTeleportFrame), so this suffix only acknowledges
|
||||
// the result into presentation.
|
||||
_host.Host?.NotifyTeleported();
|
||||
controller.SetBodyOrientation(rotation);
|
||||
|
||||
_cameras.Legacy?.Update(controller.Position, controller.Yaw);
|
||||
_cameras.Retail?.ResetViewerToPlayer(controller.Position, controller.Yaw);
|
||||
|
|
@ -276,17 +281,6 @@ internal sealed class LocalPlayerTeleportPlacement : ILocalPlayerTeleportPlaceme
|
|||
$"live: teleport materialized - snapped to {controller.Position} "
|
||||
+ $"cell=0x{controller.CellId:X8}");
|
||||
}
|
||||
|
||||
private Vector3 CellLocalForSeed(Vector3 worldPosition, uint cellId)
|
||||
{
|
||||
int landblockX = (int)((cellId >> 24) & 0xFFu);
|
||||
int landblockY = (int)((cellId >> 16) & 0xFFu);
|
||||
var origin = new Vector3(
|
||||
(landblockX - _origin.CenterX) * 192f,
|
||||
(landblockY - _origin.CenterY) * 192f,
|
||||
0f);
|
||||
return worldPosition - origin;
|
||||
}
|
||||
}
|
||||
|
||||
internal interface ILocalPlayerTeleportSession
|
||||
|
|
@ -397,11 +391,28 @@ internal sealed class LocalPlayerTeleportController
|
|||
private readonly ILocalPlayerTeleportPlacement _placement;
|
||||
private readonly ILocalPlayerTeleportSession _session;
|
||||
private readonly ILocalPlayerTeleportPresentation _presentation;
|
||||
private readonly RuntimeAcceptedPositionDriveController _acceptedPositionDrive;
|
||||
|
||||
private Vector3 _pendingPosition;
|
||||
private uint _pendingCell;
|
||||
private Quaternion _pendingRotation = Quaternion.Identity;
|
||||
private long _pendingRevealGeneration;
|
||||
private RuntimeTeleportDestination _pendingDestination;
|
||||
private bool _hasPendingDestination;
|
||||
|
||||
/// <summary>
|
||||
/// A1 review fix (2026-08-05): true once the canonical Runtime commit
|
||||
/// for THIS teleport lifetime has actually happened. See the class doc
|
||||
/// on <see cref="TryAdvancePortalCommit"/> for why this exists.
|
||||
/// </summary>
|
||||
private bool _placementCommitted;
|
||||
|
||||
/// <summary>
|
||||
/// A1 review fix: true while a <c>DeferredCell</c> park is outstanding
|
||||
/// for the local player's one possible pending operation. See
|
||||
/// <see cref="TryAdvancePortalCommit"/>.
|
||||
/// </summary>
|
||||
private bool _awaitingDeferredWake;
|
||||
|
||||
private float _holdSeconds;
|
||||
private long _lifetimeGeneration;
|
||||
private bool _disposed;
|
||||
|
|
@ -415,7 +426,8 @@ internal sealed class LocalPlayerTeleportController
|
|||
WorldRevealCoordinator worldReveal,
|
||||
ILocalPlayerTeleportPlacement placement,
|
||||
ILocalPlayerTeleportSession session,
|
||||
ILocalPlayerTeleportPresentation presentation)
|
||||
ILocalPlayerTeleportPresentation presentation,
|
||||
RuntimeAcceptedPositionDriveController acceptedPositionDrive)
|
||||
{
|
||||
_authority = authority ?? throw new ArgumentNullException(nameof(authority));
|
||||
_input = input ?? throw new ArgumentNullException(nameof(input));
|
||||
|
|
@ -426,6 +438,8 @@ internal sealed class LocalPlayerTeleportController
|
|||
_placement = placement ?? throw new ArgumentNullException(nameof(placement));
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_presentation = presentation ?? throw new ArgumentNullException(nameof(presentation));
|
||||
_acceptedPositionDrive = acceptedPositionDrive
|
||||
?? throw new ArgumentNullException(nameof(acceptedPositionDrive));
|
||||
}
|
||||
|
||||
public bool IsActive => _transit.IsTeleportActive;
|
||||
|
|
@ -488,21 +502,41 @@ internal sealed class LocalPlayerTeleportController
|
|||
|
||||
bool haveDestination = _pendingCell != 0u;
|
||||
bool originReady = !_streaming.IsRecenterPending;
|
||||
bool ready = haveDestination
|
||||
bool dataReady = haveDestination
|
||||
&& originReady
|
||||
&& _worldReveal.Evaluate(_pendingCell).IsReady;
|
||||
if (!IsCurrentLifetime(generation, sequence))
|
||||
return;
|
||||
|
||||
if (haveDestination && !ready)
|
||||
// A1 review fix (2026-08-05, retail/architecture review): the
|
||||
// sequencer's Tunnel -> TunnelContinue transition
|
||||
// (TeleportAnimSequencer.cs:134-141) is unconditional and
|
||||
// irreversible the instant it observes `worldReady` true; by the
|
||||
// time a failed placement is discovered the stream has already left
|
||||
// Tunnel with no path back, and TeleportAnimSequencer itself is
|
||||
// untouched (stop condition 2 forbids sequencer timing changes). So
|
||||
// the boolean fed into the sequencer must never mean "the data is
|
||||
// ready" alone - it must mean "the canonical Runtime commit has
|
||||
// ALREADY happened", checked/attempted fresh every tick via
|
||||
// TryAdvancePortalCommit. This makes D-T5 row 2's "the NEXT Tick
|
||||
// re-attempts the Place edge" real: the sequencer simply never
|
||||
// leaves Tunnel while the commit keeps refusing, and by the time it
|
||||
// finally does leave Tunnel and fire Place, TryAdvancePortalCommit
|
||||
// has already made the Runtime side succeed - the Place-event
|
||||
// handler below only ever runs the presentation suffix.
|
||||
bool placementReady = dataReady && TryAdvancePortalCommit(sequence);
|
||||
if (!IsCurrentLifetime(generation, sequence))
|
||||
return;
|
||||
|
||||
if (haveDestination && !placementReady)
|
||||
_holdSeconds += deltaSeconds;
|
||||
_presentation.SetWaitCue(
|
||||
haveDestination
|
||||
&& !ready
|
||||
&& !placementReady
|
||||
&& _worldReveal.ObserveWait(
|
||||
TimeSpan.FromSeconds(_holdSeconds)));
|
||||
|
||||
var (_, events) = _presentation.Tick(deltaSeconds, ready);
|
||||
var (_, events) = _presentation.Tick(deltaSeconds, placementReady);
|
||||
if (!IsCurrentLifetime(generation, sequence))
|
||||
return;
|
||||
|
||||
|
|
@ -511,17 +545,32 @@ internal sealed class LocalPlayerTeleportController
|
|||
switch (teleportEvent)
|
||||
{
|
||||
case TeleportAnimEvent.Place:
|
||||
// TryAdvancePortalCommit above is the only path that
|
||||
// makes `placementReady` (and, with the REAL sequencer,
|
||||
// this event) true, so the canonical Runtime commit has
|
||||
// ALREADY succeeded by construction on that path - this
|
||||
// only runs the presentation suffix (D-T4). The
|
||||
// _placementCommitted re-check stays defensive: it is
|
||||
// the exact same shape of guard IsCurrentLifetime below
|
||||
// already applies to every other step of this case, for
|
||||
// a transit that goes stale between the gate above and
|
||||
// this line being reached.
|
||||
if (!_placementCommitted)
|
||||
return;
|
||||
// B7 review fix (2026-08-05): re-derived, not assumed -
|
||||
// if the reveal was cancelled/superseded in the window
|
||||
// between the commit above and this event being
|
||||
// processed, ObserveMaterialized below would refuse but
|
||||
// Place/the presentation suffix would already have run
|
||||
// against a reveal that is no longer current. Same
|
||||
// check TryExecuteCanonicalPortalPlacementCore itself
|
||||
// gates on; idempotent to repeat here.
|
||||
if (!_worldReveal.CanPlacePortalDestination(
|
||||
_pendingRevealGeneration,
|
||||
sequence,
|
||||
_pendingCell))
|
||||
_pendingRevealGeneration, sequence, _pendingCell))
|
||||
{
|
||||
return;
|
||||
}
|
||||
_placement.Place(
|
||||
_pendingPosition,
|
||||
_pendingCell,
|
||||
_pendingRotation);
|
||||
_placement.Place(_pendingRotation);
|
||||
if (!IsCurrentLifetime(generation, sequence))
|
||||
return;
|
||||
_worldReveal.ObserveMaterialized(
|
||||
|
|
@ -569,6 +618,193 @@ internal sealed class LocalPlayerTeleportController
|
|||
_presentation.TickTunnel(deltaSeconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A1 review fix (2026-08-05): the one gate that decides whether the
|
||||
/// anim sequencer is allowed to see <c>worldReady=true</c>. Returns
|
||||
/// <see langword="true"/> ONLY once the canonical Runtime commit for
|
||||
/// THIS teleport lifetime has actually happened — never speculatively,
|
||||
/// never optimistically. Three states:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Already committed
|
||||
/// (<see cref="_placementCommitted"/>) — returns
|
||||
/// <see langword="true"/> immediately, every subsequent Tick.</description></item>
|
||||
/// <item><description>A <c>DeferredCell</c> park is outstanding
|
||||
/// (<see cref="_awaitingDeferredWake"/>) — polls
|
||||
/// <see cref="RuntimeAcceptedPositionDriveController.PendingCount"/>
|
||||
/// only to decide whether to re-attempt the Runtime call: Runtime's own
|
||||
/// Begin would just refuse a second overlapping attempt with
|
||||
/// <c>Contention</c> while a park is outstanding (the drive tracks at
|
||||
/// most one pending operation for the local player), so retrying blind
|
||||
/// would only add noise. B1 review fix (2026-08-05): once
|
||||
/// <c>PendingCount</c> returns to 0 the park is DONE, but "done" is not
|
||||
/// "committed" — the drive's own doc names a merge-time <c>Forget</c>
|
||||
/// (an ordinary ACE broadcast arriving mid-park) as the EXPECTED way a
|
||||
/// park resolves without committing, and A2's own abandon branches are
|
||||
/// a second way. The OLD code inferred commit from the empty slot
|
||||
/// alone; this now asks
|
||||
/// <see cref="RuntimeAcceptedPositionDriveController.TryConsumePortalCommit"/>,
|
||||
/// which the drive latches ONLY inside a REAL
|
||||
/// <c>ReconcileAndAcknowledgePortal</c> call, keyed to this exact
|
||||
/// reveal generation/sequence. A "no" here is NOT a failure — it just
|
||||
/// means the park ended without placing, so <see cref="_awaitingDeferredWake"/>
|
||||
/// clears and the method falls through to a fresh attempt below,
|
||||
/// safely (nothing is pending anymore).</description></item>
|
||||
/// <item><description>Neither — attempt the canonical placement fresh
|
||||
/// this tick. <c>Committed</c> latches
|
||||
/// <see cref="_placementCommitted"/>; <c>DeferredCell</c> latches
|
||||
/// <see cref="_awaitingDeferredWake"/>; every other status (Contention,
|
||||
/// Rejected, NotApplicable, or the transit no longer owning this
|
||||
/// reveal) is the D-T5 refusal shape — nothing mutates, and the SAME
|
||||
/// predicate retries automatically on the NEXT Tick, which is what
|
||||
/// makes D-T5 row 2's "the next Tick re-attempts the Place edge"
|
||||
/// mechanism real without ever touching
|
||||
/// <see cref="AcDream.Core.World.TeleportAnimSequencer"/>.</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private bool TryAdvancePortalCommit(ushort sequence)
|
||||
{
|
||||
if (_placementCommitted)
|
||||
return true;
|
||||
|
||||
if (_awaitingDeferredWake)
|
||||
{
|
||||
if (_acceptedPositionDrive.PendingCount != 0)
|
||||
return false;
|
||||
_awaitingDeferredWake = false;
|
||||
if (_acceptedPositionDrive.TryConsumePortalCommit(
|
||||
_pendingRevealGeneration, sequence))
|
||||
{
|
||||
_placementCommitted = true;
|
||||
return true;
|
||||
}
|
||||
// The park ended without placing (Forgotten, or abandoned by
|
||||
// A2's re-validation). Fall through to the fresh-attempt path
|
||||
// below in this SAME call — nothing is pending, so it is safe.
|
||||
}
|
||||
|
||||
if (!_worldReveal.CanPlacePortalDestination(
|
||||
_pendingRevealGeneration,
|
||||
sequence,
|
||||
_pendingCell))
|
||||
{
|
||||
PhysicsDiagnostics.LogTeleport(
|
||||
"REFUSED", _pendingCell, "cause=stale-reveal");
|
||||
// R8 residual fix (2026-08-05): this refusal previously only
|
||||
// logged through PhysicsDiagnostics.LogTeleport, gated by the
|
||||
// DIFFERENT ACDREAM_PROBE_TELEPORT flag — invisible under
|
||||
// ACDREAM_PROBE_LOCAL_TELEPORT, the gate the rest of this
|
||||
// route's arrival/commit lines use. No placement was attempted,
|
||||
// so there is no resolved cell/leash/autorun fact to report.
|
||||
PhysicsDiagnostics.LogLocalTeleportArrival(
|
||||
cause: "stale-reveal",
|
||||
placementStatus: "Refused",
|
||||
portalGeneration: _pendingRevealGeneration,
|
||||
teleportSequence: sequence,
|
||||
destinationCell: _pendingCell,
|
||||
resolvedCell: 0u,
|
||||
hookTailRan: false,
|
||||
leashArmed: false,
|
||||
autorunCancelled: false);
|
||||
return false;
|
||||
}
|
||||
|
||||
RuntimeAcceptedPositionExecutionStatus status =
|
||||
TryExecuteCanonicalPortalPlacementCore(sequence);
|
||||
switch (status)
|
||||
{
|
||||
case RuntimeAcceptedPositionExecutionStatus.Committed:
|
||||
_placementCommitted = true;
|
||||
return true;
|
||||
case RuntimeAcceptedPositionExecutionStatus.DeferredCell:
|
||||
_awaitingDeferredWake = true;
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 3 (D-T1/D-T2): builds the producer's
|
||||
/// <see cref="RuntimePortalPlacementAuthority"/> from live transit facts
|
||||
/// and drives the canonical Runtime portal arm. No new
|
||||
/// <see cref="WorldRevealCoordinator"/> exposure is needed — the host
|
||||
/// token is RE-DERIVED through the transit owner's idempotent
|
||||
/// <c>TryRegisterHostProjection</c> (the same generation+cell returns
|
||||
/// the token <see cref="WorldRevealCoordinator.TryBeginPortal"/> already
|
||||
/// registered at Aim time; a stale generation, wrong cell, cancelled, or
|
||||
/// completed reveal refuses), which makes a superseded token unobtainable
|
||||
/// by construction.
|
||||
///
|
||||
/// <para>
|
||||
/// The destination itself is <see cref="_pendingDestination"/> — the
|
||||
/// value <see cref="AimDestination"/> captured at Aim time — and NOT a
|
||||
/// fresh <c>_transit.TryGetAcceptedTeleportDestination</c> read.
|
||||
/// <see cref="RuntimeWorldTransitState.TryBeginPortalReveal"/> atomically
|
||||
/// CONSUMES the transit's one accepted-destination slot the instant Aim
|
||||
/// claims the reveal generation (it clears
|
||||
/// <c>_hasAcceptedDestination</c> so a stale destination can never be
|
||||
/// re-claimed by a later portal) — by Place time that slot is already
|
||||
/// empty, so re-querying it here always fails. This mirrors why
|
||||
/// <see cref="_pendingCell"/>/<see cref="_pendingRotation"/>/
|
||||
/// <see cref="_pendingRevealGeneration"/> are themselves Aim-time
|
||||
/// snapshots rather than live transit reads.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// A9 review fix (2026-08-05): <paramref name="sequence"/> is
|
||||
/// <see cref="_pendingDestination"/>'s OWN
|
||||
/// <see cref="RuntimeTeleportDestination.TeleportSequence"/>, not the
|
||||
/// transit's separately-tracked <c>ActiveTeleportSequence</c> the caller
|
||||
/// otherwise threads through — one source for the fact this method's
|
||||
/// authority carries, asserted equal to the caller's copy so the two
|
||||
/// can never silently diverge.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private RuntimeAcceptedPositionExecutionStatus
|
||||
TryExecuteCanonicalPortalPlacementCore(ushort sequence)
|
||||
{
|
||||
System.Diagnostics.Debug.Assert(
|
||||
!_hasPendingDestination
|
||||
|| _pendingDestination.TeleportSequence == sequence,
|
||||
"The transit's active sequence and the Aim-time destination's "
|
||||
+ "own sequence must never diverge (A9).");
|
||||
if (!_hasPendingDestination
|
||||
|| !_transit.TryRegisterHostProjection(
|
||||
_pendingRevealGeneration,
|
||||
_pendingCell,
|
||||
out RuntimeWorldHostProjectionToken hostToken))
|
||||
{
|
||||
PhysicsDiagnostics.LogTeleport(
|
||||
"REFUSED", _pendingCell, "cause=host-token-unavailable");
|
||||
// R8 residual fix (2026-08-05): same rationale as the
|
||||
// stale-reveal refusal above — route through
|
||||
// LogLocalTeleportArrival too, so ACDREAM_PROBE_LOCAL_TELEPORT
|
||||
// alone is enough to see every App-side refusal cause.
|
||||
PhysicsDiagnostics.LogLocalTeleportArrival(
|
||||
cause: "host-token-unavailable",
|
||||
placementStatus: "Refused",
|
||||
portalGeneration: _pendingRevealGeneration,
|
||||
teleportSequence: sequence,
|
||||
destinationCell: _pendingCell,
|
||||
resolvedCell: 0u,
|
||||
hookTailRan: false,
|
||||
leashArmed: false,
|
||||
autorunCancelled: false);
|
||||
return RuntimeAcceptedPositionExecutionStatus.Rejected;
|
||||
}
|
||||
|
||||
RuntimeTeleportDestination destination = _pendingDestination;
|
||||
var portal = new RuntimePortalPlacementAuthority(
|
||||
Present: true,
|
||||
RevealGeneration: _pendingRevealGeneration,
|
||||
TeleportSequence: destination.TeleportSequence,
|
||||
Projection: hostToken);
|
||||
return _acceptedPositionDrive.TryExecuteAcceptedPortalArrival(
|
||||
destination,
|
||||
portal);
|
||||
}
|
||||
|
||||
public void ResetSession()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
|
@ -695,7 +931,6 @@ internal sealed class LocalPlayerTeleportController
|
|||
if (!IsCurrentLifetime(generation, sequence))
|
||||
return false;
|
||||
|
||||
Vector3 worldPosition;
|
||||
if (transition.ChangesStreamingCenter)
|
||||
{
|
||||
bool isSealedDungeon = _streaming.IsSealedDungeon(
|
||||
|
|
@ -709,19 +944,24 @@ internal sealed class LocalPlayerTeleportController
|
|||
isSealedDungeon);
|
||||
if (!IsCurrentLifetime(generation, sequence))
|
||||
return false;
|
||||
worldPosition = new Vector3(
|
||||
position.Frame.Origin.X,
|
||||
position.Frame.Origin.Y,
|
||||
position.Frame.Origin.Z);
|
||||
}
|
||||
else
|
||||
{
|
||||
worldPosition = translated;
|
||||
}
|
||||
|
||||
// C4 route 3: the App-frame-translated `translated`/`worldPosition`
|
||||
// vector is no longer carried past this point — the canonical
|
||||
// portal arm resolves the placement through Runtime's OWN world
|
||||
// frame (resolveWorldOffsetFromRuntimeFrame: true), using the
|
||||
// cell-local `destination` Position captured HERE rather than an
|
||||
// App-translated snapshot (trap T4). It must be captured here and
|
||||
// NOT re-read from the transit at the Place edge:
|
||||
// _worldReveal.TryBeginPortal (above) drives
|
||||
// RuntimeWorldTransitState.TryBeginPortalReveal, which atomically
|
||||
// CONSUMES the transit's one accepted-destination slot the instant
|
||||
// it claims this reveal generation — a later
|
||||
// TryGetAcceptedTeleportDestination call always finds it empty.
|
||||
_pendingRotation = position.Frame.Orientation;
|
||||
_pendingPosition = worldPosition;
|
||||
_pendingCell = position.ObjCellId;
|
||||
_pendingDestination = destination;
|
||||
_hasPendingDestination = true;
|
||||
_holdSeconds = 0f;
|
||||
PhysicsDiagnostics.LogTeleport(
|
||||
"AIM",
|
||||
|
|
@ -739,10 +979,13 @@ internal sealed class LocalPlayerTeleportController
|
|||
{
|
||||
long generation = checked(++_lifetimeGeneration);
|
||||
|
||||
_pendingPosition = default;
|
||||
_pendingCell = 0u;
|
||||
_pendingRotation = Quaternion.Identity;
|
||||
_pendingRevealGeneration = 0;
|
||||
_pendingDestination = default;
|
||||
_hasPendingDestination = false;
|
||||
_placementCommitted = false;
|
||||
_awaitingDeferredWake = false;
|
||||
_holdSeconds = 0f;
|
||||
|
||||
_streaming.ResetRecenter(clearSession);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue