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
|
|
@ -586,7 +586,21 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
() => d.PlayerIdentity.ServerGuid,
|
||||
() => d.PlayerController.Controller,
|
||||
() => d.Character.UsePositionFromServer,
|
||||
() => liveSessionSource.CurrentSession);
|
||||
() => liveSessionSource.CurrentSession,
|
||||
// C4 route 3: the portal arm's PlayerTeleported port needs the
|
||||
// J5.4 autorun latch owner, one level above the raw controller.
|
||||
() => d.PlayerController,
|
||||
// A2/D-T2.4 review fix (2026-08-05): the SAME idempotent query
|
||||
// the Place edge itself uses (WorldRevealCoordinator.
|
||||
// CanPlacePortalDestination -> RuntimeWorldTransitState) lets a
|
||||
// DeferredCell wake re-validate before reconciling instead of
|
||||
// running the ack suffix against a reveal that ended or was
|
||||
// superseded while the park sat outstanding.
|
||||
isPortalAuthorityCurrent: portal => live.WorldTransit
|
||||
.CanPlacePortalDestination(
|
||||
portal.RevealGeneration,
|
||||
portal.TeleportSequence,
|
||||
portal.Projection.DestinationCell));
|
||||
// C4 route 4b-2 (2026-08-04): the graphical remote-placement drive
|
||||
// controller — route 4b-1's dormant owner, now driven by the remote
|
||||
// far snap. Its service window is the graphical host's near-tier
|
||||
|
|
@ -886,16 +900,17 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
live.WorldTransit,
|
||||
worldReveal,
|
||||
new LocalPlayerTeleportPlacement(
|
||||
d.PhysicsEngine,
|
||||
live.LiveEntities,
|
||||
d.PlayerIdentity,
|
||||
d.PlayerController,
|
||||
d.PlayerHost,
|
||||
d.ChaseCameraInput,
|
||||
d.WorldOrigin,
|
||||
liveSpatialReconciler),
|
||||
new LocalPlayerTeleportSession(liveSessionSource),
|
||||
presentation);
|
||||
presentation,
|
||||
// C4 route 3: the portal arm shares route 2's Runtime
|
||||
// SetPosition drive controller.
|
||||
acceptedPositionDrive);
|
||||
|
||||
LocalPlayerTeleportController CreateLocalTeleportWithTunnel(
|
||||
PortalTunnelPresentation portalTunnel) =>
|
||||
|
|
|
|||
|
|
@ -2273,13 +2273,28 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
// Position resolves to NoPositionOperation (retail's airborne
|
||||
// no-op — writes nothing at all) or Interpolate (retail's near
|
||||
// InterpolateTo queue — no direct body write here) never receives
|
||||
// the generic write below. The local player never reaches this
|
||||
// generic-remote code path at all. C4 route 4b-2 routes the >=96 m
|
||||
// far snap and C4 route 4b-3 routes the teleport/cell-less
|
||||
// classification through the canonical Runtime placement owner
|
||||
// the generic write below. C4 route 4b-2 routes the >=96 m far snap
|
||||
// and C4 route 4b-3 routes the teleport/cell-less classification
|
||||
// through the canonical Runtime placement owner
|
||||
// (ApplyRemoteContactRouting); a rejected authority or payload, and
|
||||
// "no classification at all", take the stated UnroutedCatchUp
|
||||
// policy (RuntimeRemoteFarSnapPosition.ResolveArm).
|
||||
//
|
||||
// CORRECTED 2026-08-04 (C4 route 3, process rule 6 — this comment
|
||||
// used to claim "the local player never reaches this generic-remote
|
||||
// code path at all", which is FALSE): for the local player,
|
||||
// `earlyRemoteRoute` is null and `OwnsSteadyState(null)` is false
|
||||
// (RuntimeRemoteSteadyStatePosition.cs — both pattern matches fail
|
||||
// on null), so every accepted local Apply — including the portal
|
||||
// DESTINATION Position itself — reaches and runs the generic write
|
||||
// + rebucket below, writing the raw wire pose onto the local
|
||||
// player's WorldEntity while portal space still covers the
|
||||
// viewport. This is pre-existing, tolerated (AD-2/AP-131/#275
|
||||
// territory — see the D-T7 discussion in
|
||||
// docs/research/2026-08-04-c4-route-3-contract.md), and overwritten
|
||||
// by the committed portal Place receipt's presentation suffix
|
||||
// (LocalPlayerTeleportPlacement.Place) once the canonical Runtime
|
||||
// commit lands. Route 3 does not suppress it.
|
||||
|
||||
TryApplyGenericRemoteRenderPose(
|
||||
entity,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -102,7 +102,22 @@ internal sealed class RuntimePlacementPresentationSink
|
|||
projection.Token.Portal,
|
||||
projection.Token.ExactCellId))
|
||||
{
|
||||
return false;
|
||||
// B2 review fix (2026-08-05): acknowledge-and-ignore, same shape
|
||||
// as Discard/ExecutorCompleted/WithdrawalRestored above. A Place
|
||||
// whose portal authority went stale (the transit ended or was
|
||||
// superseded WHILE a DeferredCell park sat outstanding — the
|
||||
// residual A1's readiness-hold does not close, since it only
|
||||
// protects the ORDINARY in-flight case) must not be left
|
||||
// refused at the FIFO head: RuntimePlacementProjectionSubscription
|
||||
// .OnPlacement never calls Acknowledge on a `false` return, so a
|
||||
// refused receipt wedges EVERY later entity's placement receipt
|
||||
// behind it forever. This runs SYNCHRONOUSLY at publish
|
||||
// (RuntimeAcceptedPositionDriveController's A2 re-validation, by
|
||||
// contrast, only runs downstream of a receipt this gate ALREADY
|
||||
// let through — it cannot protect this path). The canonical
|
||||
// body already committed via RetryDeferred; there is simply no
|
||||
// live presentation authority left to apply it to.
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!_liveEntities.TryApplyRuntimePlacementProjection(in projection))
|
||||
|
|
|
|||
|
|
@ -1171,6 +1171,63 @@ public static class PhysicsDiagnostics
|
|||
$"[tp-probe] {point,-6} id=0x{id:X8} t={Environment.TickCount64} {extra}"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 3 D-T8 (2026-08-04 — TEMPORARY, strip with the rest of the
|
||||
/// physics-probe family once the connected gate is scored). One line per
|
||||
/// local-player portal-arrival attempt from
|
||||
/// <c>RuntimeAcceptedPositionDriveController.ReconcileAndAcknowledgePortal</c>
|
||||
/// — the single Runtime chokepoint both the graphical and headless hosts
|
||||
/// share, so this is dual-host parity evidence, not per-host guesswork.
|
||||
/// Initial state from <c>ACDREAM_PROBE_LOCAL_TELEPORT=1</c>.
|
||||
/// </summary>
|
||||
public static bool ProbeLocalTeleportEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_LOCAL_TELEPORT") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// Which host process is running — set once at composition startup by
|
||||
/// each host's own entry point (<c>SessionPlayerComposition</c> for
|
||||
/// graphical, <c>HeadlessSessionHost</c> for headless). Runtime itself
|
||||
/// stays presentation-agnostic (Slice K); this is a diagnostics-only
|
||||
/// label so <see cref="LogLocalTeleportArrival"/> can report which
|
||||
/// process produced a given line without threading a host parameter
|
||||
/// through the drive controller's constructor.
|
||||
/// </summary>
|
||||
public static string LocalTeleportHostKind { get; set; } = "graphical";
|
||||
|
||||
/// <summary>
|
||||
/// One <c>[local-tp]</c> line: cause, host, placement status, portal
|
||||
/// generation/sequence, destination cell, resolved cell, and the three
|
||||
/// D-T8 booleans confirming the reconcile suffix actually ran
|
||||
/// (<paramref name="hookTailRan"/> = <c>CommitCanonicalTeleportFrame</c>
|
||||
/// executed, <paramref name="leashArmed"/> = the constraint leash is
|
||||
/// armed post-commit, <paramref name="autorunCancelled"/> =
|
||||
/// <c>CancelAutoRun</c> ran). Self-guards on
|
||||
/// <see cref="ProbeLocalTeleportEnabled"/>. <paramref name="cause"/> is
|
||||
/// always <c>"portal"</c> today — ACE's recall/admin teleports arrive as
|
||||
/// the identical TeleportAdvanced Position and are indistinguishable
|
||||
/// from a doorway portal at this layer; the parameter exists so a future
|
||||
/// wire-level cause signal has somewhere to land without a probe
|
||||
/// signature change.
|
||||
/// </summary>
|
||||
public static void LogLocalTeleportArrival(
|
||||
string cause,
|
||||
string placementStatus,
|
||||
long portalGeneration,
|
||||
ushort teleportSequence,
|
||||
uint destinationCell,
|
||||
uint resolvedCell,
|
||||
bool hookTailRan,
|
||||
bool leashArmed,
|
||||
bool autorunCancelled)
|
||||
{
|
||||
if (!ProbeLocalTeleportEnabled) return;
|
||||
string hookTailText = hookTailRan ? "ran" : "skipped";
|
||||
string leashText = leashArmed ? "armed" : "unarmed";
|
||||
string autorunText = autorunCancelled ? "cancelled" : "unchanged";
|
||||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[local-tp] cause={cause} host={LocalTeleportHostKind} status={placementStatus} gen={portalGeneration} seq={teleportSequence} dest=0x{destinationCell:X8} resolved=0x{resolvedCell:X8} hookTail={hookTailText} leash={leashText} autorun={autorunText}"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A6.P3 issue #98 step-walk investigation (2026-05-23). When true,
|
||||
/// emits one <c>[step-walk]</c> line at selected points in the transition
|
||||
|
|
|
|||
|
|
@ -97,16 +97,26 @@ internal sealed class HeadlessRuntimePlacementProjectionSink
|
|||
if (projection.Kind is not RuntimePlacementProjectionKind.Place)
|
||||
return false;
|
||||
|
||||
return record.PositionAuthorityVersion
|
||||
== token.PositionAuthorityVersion
|
||||
&& record.SpatialAuthorityVersion
|
||||
== token.SpatialAuthorityVersion
|
||||
&& record.PlacementCommitVersion
|
||||
== token.PlacementCommitVersion
|
||||
&& record.FullCellId == token.ExactCellId
|
||||
&& _runtime.TransitOwner.IsCurrentPlacementAuthority(
|
||||
if (record.PositionAuthorityVersion != token.PositionAuthorityVersion
|
||||
|| record.SpatialAuthorityVersion != token.SpatialAuthorityVersion
|
||||
|| record.PlacementCommitVersion != token.PlacementCommitVersion
|
||||
|| record.FullCellId != token.ExactCellId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_runtime.TransitOwner.IsCurrentPlacementAuthority(
|
||||
token.Portal,
|
||||
token.ExactCellId);
|
||||
token.ExactCellId))
|
||||
{
|
||||
// B2 review fix (2026-08-05): acknowledge-and-ignore, the same
|
||||
// shape and reasoning as the graphical sink's identical fix
|
||||
// (RuntimePlacementPresentationSink.TryApply) — a stale portal
|
||||
// authority must not wedge the ordered FIFO for every entity.
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool HasValidPortalShape(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using AcDream.Headless.Credentials;
|
|||
using AcDream.Headless.Diagnostics;
|
||||
using AcDream.Headless.Policies;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime.Physics;
|
||||
|
|
@ -143,6 +144,16 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
private RuntimeAcceptedPositionDriveController? _acceptedPositionDrive;
|
||||
private AcDream.Core.Net.WorldSession? _currentSession;
|
||||
private HeadlessSessionWorldProjection? _worldProjection;
|
||||
/// <summary>
|
||||
/// A1/A3 review fix (2026-08-05): retained so <see cref="Tick"/> can
|
||||
/// pump <see cref="RuntimeLiveEntitySessionController.PumpPortalCompletion"/>
|
||||
/// alongside <see cref="_worldProjection"/>'s own
|
||||
/// <c>PumpFirstEntry</c> — a parked portal placement must retry on the
|
||||
/// host's own per-tick cadence rather than the completion sequence
|
||||
/// running unconditionally the instant it is first attempted. Reassigned
|
||||
/// on every reconnect exactly like <see cref="_worldProjection"/>.
|
||||
/// </summary>
|
||||
private RuntimeLiveEntitySessionController? _entities;
|
||||
/// <summary>C4 route 4b-1 (N3): the exact route <see cref="CreateEventRoute"/>
|
||||
/// last constructed, so <see cref="Tick"/> can republish the canonical
|
||||
/// placement FIFO every tick — mirrors the graphical host's per-frame
|
||||
|
|
@ -332,6 +343,12 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
// collision-generation progress and freshly accepted Creates both
|
||||
// surface here, mirroring the graphical per-frame retry phase.
|
||||
_worldProjection?.PumpFirstEntry();
|
||||
// A1/A3 review fix (2026-08-05): retry a parked portal completion
|
||||
// (see RuntimeLiveEntitySessionController.PumpPortalCompletion) on
|
||||
// the SAME per-tick cadence, after first-entry so a DeferredCell
|
||||
// wake first-entry's own pump just resolved is picked up the same
|
||||
// tick.
|
||||
_entities?.PumpPortalCompletion();
|
||||
// C4 route 4b-1 (N3): republish the canonical placement FIFO LAST,
|
||||
// same order as the graphical host's retry-lease callback (drives
|
||||
// first, retry last) — a declined Place left at the FIFO head by
|
||||
|
|
@ -621,6 +638,13 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
Radius: 0.48f,
|
||||
Height: 1.835f,
|
||||
RuntimeLocalPlayerShadowDisposition.ProvenShapeless));
|
||||
// D-T8 (temporary probe): labels every subsequent
|
||||
// PhysicsDiagnostics.LogLocalTeleportArrival line from THIS
|
||||
// process as headless — Runtime itself has no host-kind concept
|
||||
// (Slice K keeps it presentation-agnostic), so this is a
|
||||
// diagnostics-only label set once at composition time, not a
|
||||
// Runtime dependency.
|
||||
PhysicsDiagnostics.LocalTeleportHostKind = "headless";
|
||||
// C4 route 2: one drive controller per host, mirroring
|
||||
// _firstEntryDrive exactly — same persistent Runtime lifetime,
|
||||
// collision source, and clock.
|
||||
|
|
@ -633,7 +657,20 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
() => Runtime.PlayerIdentity.ServerGuid,
|
||||
() => Runtime.MovementOwner.Controller,
|
||||
() => Runtime.CharacterOwner.UsePositionFromServer,
|
||||
() => _currentSession);
|
||||
() => _currentSession,
|
||||
// C4 route 3: the portal arm's PlayerTeleported port needs
|
||||
// the J5.4 autorun latch owner, one level above the raw
|
||||
// controller.
|
||||
() => Runtime.MovementOwner,
|
||||
// A2/D-T2.4 review fix (2026-08-05): same wiring as the
|
||||
// graphical composition (SessionPlayerComposition.cs) — the
|
||||
// SAME idempotent query TryCompletePortal/PrepareDestination
|
||||
// themselves use.
|
||||
isPortalAuthorityCurrent: portal => Runtime.TransitOwner
|
||||
.CanPlacePortalDestination(
|
||||
portal.RevealGeneration,
|
||||
portal.TeleportSequence,
|
||||
portal.Projection.DestinationCell));
|
||||
var projection = new HeadlessSessionWorldProjection(
|
||||
Runtime,
|
||||
content,
|
||||
|
|
@ -651,6 +688,7 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
Runtime.Generation.Value),
|
||||
worldProjection,
|
||||
_acceptedPositionDrive);
|
||||
_entities = entities;
|
||||
var route = new LiveSessionEventRouter(
|
||||
session,
|
||||
entities.CreateSink(),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ using AcDream.Runtime.Entities;
|
|||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime.Physics;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.Runtime.World;
|
||||
|
||||
namespace AcDream.Headless.Hosting;
|
||||
|
||||
|
|
@ -585,9 +586,6 @@ internal sealed class HeadlessCollisionNeighborhood
|
|||
internal sealed class HeadlessSessionWorldProjection
|
||||
: IRuntimeDirectWorldProjection
|
||||
{
|
||||
private const float DefaultRadius = 0.48f;
|
||||
private const float DefaultHeight = 1.835f;
|
||||
|
||||
private readonly GameRuntime _runtime;
|
||||
private readonly IHeadlessCollisionNeighborhood _collision;
|
||||
private readonly RuntimeFirstEntryDriveController? _firstEntry;
|
||||
|
|
@ -749,90 +747,228 @@ internal sealed class HeadlessSessionWorldProjection
|
|||
controller.State = PlayerState.PortalSpace;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A1/A3 review fix (2026-08-05): true while a <c>DeferredCell</c> park
|
||||
/// from a PRIOR call to this method is outstanding for the local
|
||||
/// player's one possible pending drive operation. Mirrors
|
||||
/// <c>LocalPlayerTeleportController._awaitingDeferredWake</c> on the
|
||||
/// graphical side — avoids re-attempting
|
||||
/// <c>TryExecuteAcceptedPortalArrival</c> while parked (Runtime's own
|
||||
/// Begin would just refuse a second overlapping attempt with
|
||||
/// <c>Contention</c>) by polling
|
||||
/// <see cref="RuntimeAcceptedPositionDriveController.PendingCount"/>
|
||||
/// instead.
|
||||
/// </summary>
|
||||
private bool _awaitingPortalWake;
|
||||
|
||||
/// <summary>
|
||||
/// B3 review fix (2026-08-05): keys <see cref="_awaitingPortalWake"/> to
|
||||
/// the exact reveal it was armed for. The graphical twin
|
||||
/// (<c>_awaitingDeferredWake</c>) is naturally reset per teleport via
|
||||
/// <c>ResetTransit</c>; this class is constructed per SESSION, not per
|
||||
/// teleport, so without this a stale latch from reveal N could silently
|
||||
/// steal reveal N+1's <c>PrepareDestination</c> call into polling a park
|
||||
/// that belongs to a different, already-abandoned reveal — skipping the
|
||||
/// new reveal's placement attempt entirely.
|
||||
/// </summary>
|
||||
private long _awaitingPortalWakeGeneration;
|
||||
private ushort _awaitingPortalWakeSequence;
|
||||
|
||||
/// <summary>
|
||||
/// N3 review fix (2026-08-05): bounds how many consecutive
|
||||
/// <c>NotApplicable</c> attempts this host tolerates before treating the
|
||||
/// condition as unrecoverable. <c>NotApplicable</c> covers hydration-race
|
||||
/// transients (no canonical body yet, an active initial-Create residence
|
||||
/// still owning the record) as well as a genuinely stale reveal — unlike
|
||||
/// <c>Rejected</c>, it is not established to be permanent, and this host
|
||||
/// must survive K4's 30-session / two-hour endurance profile without a
|
||||
/// transient becoming fatal.
|
||||
/// </summary>
|
||||
private int _notApplicableRetryCount;
|
||||
private const int NotApplicableRetryBudget = 50;
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 3 (D-T6): the portal-arrival placement runs through the
|
||||
/// SAME canonical Runtime portal arm the graphical host drives
|
||||
/// (<see cref="RuntimeAcceptedPositionDriveController.TryExecuteAcceptedPortalArrival"/>),
|
||||
/// retiring the duplicate Resolve/ResolvePlacement/SetPosition authority
|
||||
/// this method used to own directly (D2;
|
||||
/// docs/research/2026-08-04-c4-route-3-contract.md D-T6).
|
||||
///
|
||||
/// <para>
|
||||
/// A1/A3 review fix (2026-08-05): the first pass discarded the arm's
|
||||
/// returned status entirely (<c>_ = ...</c>) and always reported
|
||||
/// success, so <c>RuntimeLiveEntitySessionController.TryCompletePortal</c>
|
||||
/// acknowledged a materialization that never happened on ANY refusal
|
||||
/// (architecture review A3). This method now:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description>throws if no drive controller was wired — a
|
||||
/// composition regression must not silently disable placement, never
|
||||
/// pretend success (A3's second finding);</description></item>
|
||||
/// <item><description>does not even ATTEMPT the placement until
|
||||
/// <see cref="_collision"/> reports the destination resident — this
|
||||
/// host's narrow collision window makes a premature attempt a
|
||||
/// guaranteed <c>DeferredCell</c>, and <see cref="_collision"/>'s own
|
||||
/// readiness IS the signal <see cref="IRuntimeDirectWorldProjection.CenterOnAcceptedForcePosition"/>'s
|
||||
/// doc names as the precondition for a park to ever resolve;</description></item>
|
||||
/// <item><description>reports <c>IsCollisionReady: false</c> — never
|
||||
/// success — for every non-<c>Committed</c> outcome, so the caller's
|
||||
/// retry loop (<c>RuntimeLiveEntitySessionController.PumpPortalCompletion</c>,
|
||||
/// A1's headless-side fix) keeps calling this method instead of the
|
||||
/// completion sequence running against an unplaced body; a genuine
|
||||
/// <c>DeferredCell</c> is therefore never an error, only a wait — and
|
||||
/// throws only for the two statuses that mean something is actually
|
||||
/// wrong (<c>Rejected</c>/<c>NotApplicable</c> — the reveal itself is
|
||||
/// stale, or the local player has no canonical body, neither of which
|
||||
/// a headless bot can recover from by waiting).</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public RuntimeDestinationReadiness PrepareDestination(
|
||||
long revealGeneration,
|
||||
RuntimeTeleportDestination destination)
|
||||
RuntimeTeleportDestination destination,
|
||||
RuntimeWorldHostProjectionToken portal)
|
||||
{
|
||||
_collision.CenterOn(destination.CellId);
|
||||
if (_runtime.EntityObjects.Entities.TryGetActive(
|
||||
destination.EntityGuid,
|
||||
out RuntimeEntityRecord record))
|
||||
if (_acceptedPositionDrive is null)
|
||||
{
|
||||
ResynchronizeLocalPlayerForPortalArrival(record);
|
||||
throw new InvalidOperationException(
|
||||
"Headless portal placement requires a wired "
|
||||
+ "RuntimeAcceptedPositionDriveController - a composition "
|
||||
+ "regression must not silently disable placement (A3).");
|
||||
}
|
||||
if (_runtime.MovementOwner.Controller is { } controller)
|
||||
|
||||
// B3: a latch armed for a DIFFERENT reveal must not be consulted
|
||||
// for this one — fall through to a fresh attempt below instead.
|
||||
if (_awaitingPortalWake
|
||||
&& (_awaitingPortalWakeGeneration != revealGeneration
|
||||
|| _awaitingPortalWakeSequence != destination.TeleportSequence))
|
||||
{
|
||||
_awaitingPortalWake = false;
|
||||
}
|
||||
|
||||
bool committed;
|
||||
if (_awaitingPortalWake)
|
||||
{
|
||||
if (_acceptedPositionDrive.PendingCount != 0)
|
||||
{
|
||||
committed = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
// B1 review fix (2026-08-05): "not pending" is not
|
||||
// "committed" — the drive's own doc names a merge-time
|
||||
// Forget (an ordinary ACE broadcast arriving mid-park) as
|
||||
// the EXPECTED way a park resolves without committing.
|
||||
// TryConsumePortalCommit is the drive's OWN record of
|
||||
// whether ITS commit actually happened for this exact
|
||||
// reveal/sequence, latched only inside a real
|
||||
// ReconcileAndAcknowledgePortal call — never inferred.
|
||||
_awaitingPortalWake = false;
|
||||
committed = _acceptedPositionDrive.TryConsumePortalCommit(
|
||||
revealGeneration, destination.TeleportSequence);
|
||||
// A "no" here falls through to committed=false below; the
|
||||
// NEXT PrepareDestination call re-attempts fresh since
|
||||
// _awaitingPortalWake is now false and nothing is pending.
|
||||
}
|
||||
}
|
||||
else if (!_collision.IsReady(destination.CellId))
|
||||
{
|
||||
committed = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
var authority = new RuntimePortalPlacementAuthority(
|
||||
Present: true,
|
||||
RevealGeneration: revealGeneration,
|
||||
TeleportSequence: destination.TeleportSequence,
|
||||
Projection: portal);
|
||||
RuntimeAcceptedPositionExecutionStatus status =
|
||||
_acceptedPositionDrive.TryExecuteAcceptedPortalArrival(
|
||||
destination,
|
||||
authority);
|
||||
switch (status)
|
||||
{
|
||||
case RuntimeAcceptedPositionExecutionStatus.Committed:
|
||||
committed = true;
|
||||
_notApplicableRetryCount = 0;
|
||||
break;
|
||||
case RuntimeAcceptedPositionExecutionStatus.DeferredCell:
|
||||
_awaitingPortalWake = true;
|
||||
_awaitingPortalWakeGeneration = revealGeneration;
|
||||
_awaitingPortalWakeSequence = destination.TeleportSequence;
|
||||
committed = false;
|
||||
break;
|
||||
case RuntimeAcceptedPositionExecutionStatus.Contention:
|
||||
// Transient - some other operation still owns the
|
||||
// entity's placement token. Retried next pump; never a
|
||||
// hard error, matching the graphical arm's D-T5
|
||||
// refusal shape.
|
||||
committed = false;
|
||||
break;
|
||||
case RuntimeAcceptedPositionExecutionStatus.NotApplicable:
|
||||
// N3 review fix (2026-08-05): NotApplicable covers
|
||||
// hydration-race transients (record.PhysicsBody is
|
||||
// null, or an active initial-Create residence still
|
||||
// owns the record — RuntimeAcceptedPositionDriveController's
|
||||
// own guard) as well as a genuinely stale reveal; unlike
|
||||
// Rejected it is not established to be permanent.
|
||||
// Bounded, loud retry rather than an immediate throw —
|
||||
// this host must survive K4's 30-session/two-hour
|
||||
// endurance profile without a transient becoming fatal.
|
||||
_notApplicableRetryCount++;
|
||||
PhysicsDiagnostics.LogTeleport(
|
||||
"REFUSED",
|
||||
destination.CellId,
|
||||
$"cause=NotApplicable attempt={_notApplicableRetryCount}");
|
||||
if (_notApplicableRetryCount > NotApplicableRetryBudget)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Headless portal placement stayed NotApplicable "
|
||||
+ $"for {_notApplicableRetryCount} consecutive "
|
||||
+ "attempts (no canonical body, or an active "
|
||||
+ "initial-Create residence still owns the "
|
||||
+ "record) - exceeded the bounded retry budget.");
|
||||
}
|
||||
committed = false;
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException(
|
||||
$"Headless portal placement refused with "
|
||||
+ $"status={status} even though the destination's "
|
||||
+ "collision neighborhood reported ready - the "
|
||||
+ "reveal itself is stale, not recoverable by "
|
||||
+ "waiting (contract §4 item 5 forbids "
|
||||
+ "acknowledging a materialization that did not "
|
||||
+ "happen).");
|
||||
}
|
||||
}
|
||||
|
||||
if (committed && _runtime.MovementOwner.Controller is { } controller)
|
||||
controller.State = PlayerState.InWorld;
|
||||
|
||||
bool ready = _collision.IsReady(destination.CellId);
|
||||
bool indoor = (destination.CellId & 0xFFFFu) >= 0x0100u;
|
||||
return new RuntimeDestinationReadiness(
|
||||
revealGeneration,
|
||||
destination.CellId,
|
||||
indoor,
|
||||
IsUnhydratable: !ready,
|
||||
// N2 review fix (2026-08-05): hardcoded false, NOT DERIVED.
|
||||
// AD-2's "loud unhydratable-placement path" (a claim beyond
|
||||
// NumCells) is a graphical-only concept today —
|
||||
// WorldRevealReadinessBarrier's render/composite-texture domains
|
||||
// have no headless analogue, so there is no local predicate this
|
||||
// no-window host could evaluate. A genuinely unhydratable
|
||||
// destination therefore reports IsCollisionReady=false forever
|
||||
// (via the bounded DeferredCell retry above) rather than taking
|
||||
// AD-2's loud path — headless does not model unhydratable
|
||||
// claims. If headless ever gains its own resident-cell-set
|
||||
// concept, derive the real predicate here instead of leaving
|
||||
// this hardcoded.
|
||||
IsUnhydratable: false,
|
||||
RequiredRenderRadius: indoor ? 0 : 1,
|
||||
IsRenderNeighborhoodReady: true,
|
||||
AreCompositeTexturesReady: true,
|
||||
IsCollisionReady: ready);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TODO-C4 (route 3): portal-arrival re-synchronization only. The
|
||||
/// route-1/8 initial-entry hand-copy (controller construction + first
|
||||
/// resolve/placement) was deleted at C3c — the first-entry conductor's
|
||||
/// publication chain owns it — but the portal route is unflipped, so its
|
||||
/// arrival re-resolve keeps today's exact behavior against the
|
||||
/// already-published controller until C4 routes it through
|
||||
/// RuntimePortalPlacementAuthority.
|
||||
/// </summary>
|
||||
private void ResynchronizeLocalPlayerForPortalArrival(
|
||||
RuntimeEntityRecord record)
|
||||
{
|
||||
if (record.ServerGuid
|
||||
!= _runtime.PlayerIdentity.ServerGuid
|
||||
|| record.Snapshot.Position is not { } position
|
||||
|| _runtime.MovementOwner.Controller is not { } controller)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_collision.CenterOn(position.LandblockId);
|
||||
Vector3 wirePosition = new(
|
||||
position.PositionX,
|
||||
position.PositionY,
|
||||
position.PositionZ);
|
||||
Quaternion orientation = new(
|
||||
position.RotationX,
|
||||
position.RotationY,
|
||||
position.RotationZ,
|
||||
position.RotationW);
|
||||
|
||||
ResolveResult resolved =
|
||||
_runtime.EntityObjects.Physics.Engine.Resolve(
|
||||
wirePosition,
|
||||
position.LandblockId,
|
||||
Vector3.Zero,
|
||||
100f);
|
||||
ResolveResult placement =
|
||||
_runtime.EntityObjects.Physics.Engine.ResolvePlacement(
|
||||
resolved.Position,
|
||||
resolved.CellId,
|
||||
DefaultRadius,
|
||||
DefaultHeight,
|
||||
controller.StepUpHeight,
|
||||
controller.StepDownHeight,
|
||||
ObjectInfoState.IsPlayer
|
||||
| ObjectInfoState.EdgeSlide,
|
||||
record.LocalEntityId ?? 0u);
|
||||
if (placement.Ok)
|
||||
resolved = placement;
|
||||
|
||||
controller.LocalEntityId = record.LocalEntityId ?? 0u;
|
||||
controller.SetPosition(
|
||||
resolved.Position,
|
||||
resolved.CellId,
|
||||
wirePosition);
|
||||
controller.SetBodyOrientation(orientation);
|
||||
IsCollisionReady: committed);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -130,6 +130,11 @@ internal enum RuntimeAcceptedPositionExecutionStatus : byte
|
|||
/// early return (@0x0045409D) never reaches; the deleted
|
||||
/// <c>PlayerMovementController.BlipPosition</c>'s re-arm was an unbacked
|
||||
/// deviation this route retires (docs/research/2026-08-03-c4-route-2-implementation-plan.md §1b).
|
||||
/// This no-re-arm rule is scoped to FORCE_POSITION only — C4 route 3's
|
||||
/// portal arm (<see cref="TryExecuteAcceptedPortalArrival"/>) DOES re-arm,
|
||||
/// because retail's local TELEPORT branch of the same function reaches
|
||||
/// <c>ConstrainTo</c> @0x0045418A; see
|
||||
/// docs/research/2026-08-04-c4-route-3-contract.md §2 Inversion A.
|
||||
///
|
||||
/// One instance per host session route (graphical/headless), constructed
|
||||
/// once per host process and reused across reconnects exactly like
|
||||
|
|
@ -170,8 +175,26 @@ public sealed class RuntimeAcceptedPositionDriveController
|
|||
/// begin: that marker stands for a packet whose placement was never
|
||||
/// begun, so its ack is owed by the eventual re-issue's own terminal
|
||||
/// outcome, not by the marker.
|
||||
///
|
||||
/// C4 route 3: always <c>false</c> for a portal pending — the
|
||||
/// portal route's <see cref="RuntimeAuthoritativePositionRoute.SendPositionImmediately"/>
|
||||
/// is always <c>false</c> (retail's teleport branch never sends
|
||||
/// <c>AutonomousPosition</c>), so there is never an owed position
|
||||
/// event to carry.
|
||||
/// </summary>
|
||||
internal required bool PositionEventOwed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 3: <see cref="RuntimePortalPlacementAuthority.Present"/>
|
||||
/// when this descriptor is the trap T7 (2026-08-04 contract) portal
|
||||
/// arm's own DeferredCell park — a SIBLING use of this same
|
||||
/// retained-operation machinery, not a repurposed force pending.
|
||||
/// <see cref="Advance"/> and <see cref="SubmitAndResolvePortal"/> are
|
||||
/// the only writers/readers that branch on it; the force funnel
|
||||
/// (<see cref="SettlePending"/>, <see cref="_newestForce"/>) never
|
||||
/// sees or produces a portal pending.
|
||||
/// </summary>
|
||||
internal RuntimePortalPlacementAuthority Portal { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -218,6 +241,40 @@ public sealed class RuntimeAcceptedPositionDriveController
|
|||
private readonly Func<bool> _usePositionFromServer;
|
||||
private readonly Func<WorldSession?> _session;
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 3: the D-T3 <c>PlayerTeleported</c> port
|
||||
/// (<c>CommandInterpreter::PlayerTeleported</c> @0x006B32B0 =
|
||||
/// <c>SetAutoRun(0,1)</c> + <c>SendMovementEvent</c>) needs the J5.4
|
||||
/// autorun latch owner, which lives one level above
|
||||
/// <see cref="PlayerMovementController"/> and is not reachable from
|
||||
/// <see cref="_localController"/>. Late-bound like every other
|
||||
/// dependency here so this controller does not need to outlive a
|
||||
/// specific movement-owner instance across a reconnect.
|
||||
/// </summary>
|
||||
private readonly Func<RuntimeLocalPlayerMovementState?> _localMovementState;
|
||||
|
||||
/// <summary>
|
||||
/// A2 review fix (2026-08-05, D-T2.4): re-validates a retained portal
|
||||
/// authority against the transit owner's CURRENT reveal before either
|
||||
/// wake site in <see cref="Advance"/> acts on it. A <c>DeferredCell</c>
|
||||
/// park commits asynchronously (<c>RuntimeSetPositionState.RetryDeferred</c>,
|
||||
/// driven entirely by an unrelated collision-generation wake) — nothing
|
||||
/// in this class can prevent that body-level commit once it starts. What
|
||||
/// this predicate CAN prevent is running the reconcile/ack suffix (or a
|
||||
/// stale resubmission) against a reveal that ended or was superseded
|
||||
/// while the park was outstanding — exactly the D-T2.4 requirement this
|
||||
/// slice's first pass never implemented (architecture review A2). Wired
|
||||
/// by each host composition to
|
||||
/// <c>RuntimeWorldTransitState.CanPlacePortalDestination</c> (the SAME
|
||||
/// idempotent query <see cref="TryExecuteAcceptedPortalArrival"/>'s
|
||||
/// caller already uses at the Place edge); left <see langword="null"/>
|
||||
/// by fixtures that do not exercise the DeferredCell wake, in which case
|
||||
/// every retained portal pending is treated as still current (today's
|
||||
/// unconditional behaviour, preserved for callers that never park).
|
||||
/// </summary>
|
||||
private readonly Func<RuntimePortalPlacementAuthority, bool>?
|
||||
_isPortalAuthorityCurrent;
|
||||
|
||||
/// <summary>
|
||||
/// The drive's at-most-one in-flight placement for the local player.
|
||||
/// Round 2 unified mechanism (2026-08-03): exactly THREE members write
|
||||
|
|
@ -244,7 +301,9 @@ public sealed class RuntimeAcceptedPositionDriveController
|
|||
Func<uint> localPlayerServerGuid,
|
||||
Func<PlayerMovementController?> localController,
|
||||
Func<bool> usePositionFromServer,
|
||||
Func<WorldSession?> session)
|
||||
Func<WorldSession?> session,
|
||||
Func<RuntimeLocalPlayerMovementState?>? localMovementState = null,
|
||||
Func<RuntimePortalPlacementAuthority, bool>? isPortalAuthorityCurrent = null)
|
||||
{
|
||||
_entityObjects = entityObjects
|
||||
?? throw new ArgumentNullException(nameof(entityObjects));
|
||||
|
|
@ -262,12 +321,53 @@ public sealed class RuntimeAcceptedPositionDriveController
|
|||
_usePositionFromServer = usePositionFromServer
|
||||
?? throw new ArgumentNullException(nameof(usePositionFromServer));
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_localMovementState = localMovementState ?? (static () => null);
|
||||
_isPortalAuthorityCurrent = isPortalAuthorityCurrent;
|
||||
_entityObjects.RegisterAcceptedPositionDriveOwnership(
|
||||
() => _pending is null ? 0 : 1);
|
||||
}
|
||||
|
||||
internal int PendingCount => _pending is null ? 0 : 1;
|
||||
|
||||
/// <summary>
|
||||
/// B1 review fix (2026-08-05): the drive's own record of the LAST portal
|
||||
/// authority <see cref="ReconcileAndAcknowledgePortal"/> actually
|
||||
/// committed — set only there, so this is never an inference. Both host
|
||||
/// gates were latching "committed" from <see cref="PendingCount"/>
|
||||
/// reaching zero, but that global (force-arm-shared) slot ALSO clears on
|
||||
/// three non-committing paths (a merge-time <c>Forget</c> — the drive's
|
||||
/// own doc names this the EXPECTED outcome of a park surviving one ACE
|
||||
/// broadcast interval — and both of A2's new abandon branches), so
|
||||
/// "not pending" never implied "this specific reveal placed". Consumed
|
||||
/// exactly once per commit via <see cref="TryConsumePortalCommit"/>.
|
||||
/// </summary>
|
||||
private (long RevealGeneration, ushort TeleportSequence)? _lastCommittedPortal;
|
||||
|
||||
/// <summary>
|
||||
/// B1 review fix: the host gate's ONLY correct way to learn "did MY
|
||||
/// specific reveal commit" — never infer it from
|
||||
/// <see cref="PendingCount"/>. Returns <see langword="true"/> and
|
||||
/// consumes the fact exactly once when the drive's last portal commit
|
||||
/// matches the caller's own (revealGeneration, teleportSequence); a
|
||||
/// mismatch (nothing committed yet, a DIFFERENT reveal committed, or
|
||||
/// this generation's park was abandoned/forgotten instead) returns
|
||||
/// <see langword="false"/> without side effects, so the caller keeps
|
||||
/// retrying or falls through to a fresh attempt.
|
||||
/// </summary>
|
||||
internal bool TryConsumePortalCommit(
|
||||
long revealGeneration,
|
||||
ushort teleportSequence)
|
||||
{
|
||||
if (_lastCommittedPortal is not { } committed
|
||||
|| committed.RevealGeneration != revealGeneration
|
||||
|| committed.TeleportSequence != teleportSequence)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
_lastCommittedPortal = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C3c-R1-style one-route-at-a-time latch (mirrors
|
||||
/// <see cref="RuntimeFirstEntryDriveController.AttachRoute"/>): this
|
||||
|
|
@ -308,10 +408,22 @@ public sealed class RuntimeAcceptedPositionDriveController
|
|||
/// force observation dies with it: a reconnect re-merges its own
|
||||
/// positions, and a stale observation must never survive to authorize a
|
||||
/// re-issue against a later session's record.
|
||||
/// <para>
|
||||
/// Coordinator hygiene fix (round-3 closeout, 2026-08-05): also clears
|
||||
/// <see cref="_lastCommittedPortal"/>. An unconsumed latch surviving a
|
||||
/// session reset was harmless only because the transit's own
|
||||
/// generation counter is monotonic across resets within one
|
||||
/// <c>GameRuntime</c> lifetime, so a stale entry could never match a
|
||||
/// later reveal's generation/sequence pair by construction — a
|
||||
/// correctness argument resting on an invariant this method never
|
||||
/// declared. Clearing it here lets the ledger converge to zero on
|
||||
/// every reset instead of relying on that invariant to stay true.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void AbandonPending()
|
||||
{
|
||||
_newestForce = null;
|
||||
_lastCommittedPortal = null;
|
||||
if (_pending is not { } pending)
|
||||
return;
|
||||
_pending = null;
|
||||
|
|
@ -401,6 +513,411 @@ public sealed class RuntimeAcceptedPositionDriveController
|
|||
return SubmitAndResolve(record, token, route);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 3: executes the local player's portal arrival against the
|
||||
/// canonical Runtime SetPosition owner. Retail
|
||||
/// <c>SmartBox::TeleportPlayer</c> @0x00453910 =
|
||||
/// <c>CPhysicsObj::SetPositionSimple(player, dest, 1)</c> — the SAME
|
||||
/// generic primitive route 2 already routes through
|
||||
/// <see cref="TryExecuteAcceptedLocalPosition"/> — plus
|
||||
/// <c>PlayerPositionUpdated</c>. <paramref name="destination"/> must be
|
||||
/// the transit's OWN retained accepted destination
|
||||
/// (<c>RuntimeWorldTransitState.TryGetAcceptedTeleportDestination</c>),
|
||||
/// never re-derived from live per-tick timestamps: by the time the Place
|
||||
/// edge fires, the packet merged seconds ago and nothing is "advancing"
|
||||
/// anymore (docs/research/2026-08-04-c4-route-3-contract.md D-T2.2).
|
||||
/// </summary>
|
||||
internal RuntimeAcceptedPositionExecutionStatus TryExecuteAcceptedPortalArrival(
|
||||
in RuntimeTeleportDestination destination,
|
||||
in RuntimePortalPlacementAuthority portal)
|
||||
{
|
||||
if (!portal.IsValid
|
||||
|| !_entityObjects.Entities.TryGetActive(
|
||||
_localPlayerServerGuid(), out RuntimeEntityRecord record)
|
||||
|| record.PhysicsBody is null
|
||||
|| record.Key is not { } key
|
||||
// Route 1 owns an active initial-Create residence exactly like
|
||||
// route 2's equivalent guard above — the residence executor's
|
||||
// own tail action already carries any position it needs.
|
||||
|| _entityObjects.TryGetInitialCreateResidence(record, out _))
|
||||
{
|
||||
LogPortalArrivalAttempt(
|
||||
RuntimeAcceptedPositionExecutionStatus.NotApplicable,
|
||||
portal,
|
||||
resolvedCell: 0u);
|
||||
return RuntimeAcceptedPositionExecutionStatus.NotApplicable;
|
||||
}
|
||||
|
||||
RuntimeAuthoritativePositionRoute route = ClassifyPortalArrival(
|
||||
record, key, destination, _generation());
|
||||
if (!route.Accepted)
|
||||
{
|
||||
LogPortalArrivalAttempt(
|
||||
RuntimeAcceptedPositionExecutionStatus.Rejected,
|
||||
portal,
|
||||
record.FullCellId);
|
||||
return RuntimeAcceptedPositionExecutionStatus.Rejected;
|
||||
}
|
||||
|
||||
RuntimeSetPositionState setPosition = _entityObjects.Physics.SetPosition;
|
||||
ulong acceptedVersion = record.PositionAuthorityVersion;
|
||||
RuntimeEntityPlacementToken token =
|
||||
setPosition.TryBeginExclusiveAuthoredPlacement(
|
||||
record,
|
||||
acceptedVersion,
|
||||
route.OperationKind,
|
||||
portal);
|
||||
if (!token.IsValid)
|
||||
{
|
||||
// Either a concurrent placement authority already owns the
|
||||
// entity, or Begin's own portal-vs-latest-cell gate refused
|
||||
// (D-T5's Begin cell-mismatch edge — a second local Position
|
||||
// merged between the offer and this Place edge). Neither is
|
||||
// staleness; the caller's D-T5 refusal handling owns what
|
||||
// happens next.
|
||||
LogPortalArrivalAttempt(
|
||||
RuntimeAcceptedPositionExecutionStatus.Contention,
|
||||
portal,
|
||||
record.FullCellId);
|
||||
return RuntimeAcceptedPositionExecutionStatus.Contention;
|
||||
}
|
||||
|
||||
return SubmitAndResolvePortal(record, token, route, portal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 3: the classifier's LocalPlayer-teleport route
|
||||
/// (<see cref="RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition"/>,
|
||||
/// <c>request.Authority.TeleportAdvanced</c> branch) built from the
|
||||
/// retained destination rather than a live merge. Retail's
|
||||
/// <c>PhysicsTimestampGate.IsNewer(PreviousTeleportSequence,
|
||||
/// AcceptedTeleportSequence)</c> gate only needs to be TRUE — its exact
|
||||
/// magnitude is not read anywhere past that boolean (the classifier's
|
||||
/// resulting <see cref="RuntimeAuthoritativePositionRoute"/> for this
|
||||
/// branch does not depend on the previous stamp's value, and the drive
|
||||
/// controller's own `expectedPositionAuthorityVersion` — not this
|
||||
/// authority's — gates Begin), so a synthetic strictly-older sequence
|
||||
/// forces retail's exact branch without any second copy of the merge-time
|
||||
/// timestamp pair having to survive from offer to Place.
|
||||
/// </summary>
|
||||
private static RuntimeAuthoritativePositionRoute ClassifyPortalArrival(
|
||||
RuntimeEntityRecord record,
|
||||
RuntimeEntityKey key,
|
||||
in RuntimeTeleportDestination destination,
|
||||
RuntimeGenerationToken generation)
|
||||
{
|
||||
ushort acceptedTeleport = destination.TeleportSequence;
|
||||
ushort priorTeleport = unchecked((ushort)(acceptedTeleport - 1));
|
||||
var authority = new RuntimeAuthoritativePositionAuthority(
|
||||
generation,
|
||||
key,
|
||||
record.PositionAuthorityVersion,
|
||||
destination.PositionSequence,
|
||||
priorTeleport,
|
||||
acceptedTeleport,
|
||||
PositionTimestampDisposition.Apply);
|
||||
|
||||
bool hasAnimations = (record.Snapshot.MotionTableId
|
||||
?? record.Snapshot.Physics?.MotionTableId) is { } motionTableId
|
||||
&& motionTableId != 0u;
|
||||
|
||||
var wirePosition = new CreateObject.ServerPosition(
|
||||
destination.CellId,
|
||||
destination.Position.Frame.Origin.X,
|
||||
destination.Position.Frame.Origin.Y,
|
||||
destination.Position.Frame.Origin.Z,
|
||||
destination.Position.Frame.Orientation.W,
|
||||
destination.Position.Frame.Orientation.X,
|
||||
destination.Position.Frame.Orientation.Y,
|
||||
destination.Position.Frame.Orientation.Z);
|
||||
|
||||
var request = new RuntimeAcceptedPositionRouteRequest(
|
||||
authority,
|
||||
RuntimePositionEntityKind.LocalPlayer,
|
||||
RuntimeAcceptedPositionSource.PositionEvent,
|
||||
wirePosition,
|
||||
PlacementFrame: null,
|
||||
PositionPackVelocity: null,
|
||||
CommittedCellId: record.FullCellId,
|
||||
HasContact: false,
|
||||
PlayerDistance: 0f,
|
||||
UsePositionFromServer: false,
|
||||
hasAnimations,
|
||||
new RuntimePositionPlacementFacts(
|
||||
record.FinalPhysicsState,
|
||||
record.Snapshot.SetupTableId is not null));
|
||||
|
||||
return RuntimeAuthoritativePositionRouteClassifier
|
||||
.ClassifyAcceptedPosition(request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 3 (trap T7): the portal SIBLING of <see cref="SubmitAndResolve"/>
|
||||
/// — shares Begin/Submit/status handling, deliberately does NOT touch
|
||||
/// <see cref="_newestForce"/> or route through <see cref="SettlePending"/>'s
|
||||
/// force-shaped re-issue funnel. ACE sends one destination per teleport;
|
||||
/// a portal placement that fails to commit is never re-applied.
|
||||
/// </summary>
|
||||
private RuntimeAcceptedPositionExecutionStatus SubmitAndResolvePortal(
|
||||
RuntimeEntityRecord record,
|
||||
in RuntimeEntityPlacementToken token,
|
||||
in RuntimeAuthoritativePositionRoute route,
|
||||
in RuntimePortalPlacementAuthority portal)
|
||||
{
|
||||
RuntimeSetPositionState setPosition = _entityObjects.Physics.SetPosition;
|
||||
RuntimeSetPositionMoverPreparationStatus status =
|
||||
setPosition.TryPrepareAndSubmitAuthoredPlacement(
|
||||
record,
|
||||
token,
|
||||
route.OperationKind,
|
||||
route.SetPositionFlags,
|
||||
_collisionSource,
|
||||
_clock.SimulationTimeSeconds,
|
||||
out RuntimeSetPositionOutcome outcome,
|
||||
portal: portal,
|
||||
resolveWorldOffsetFromRuntimeFrame: true);
|
||||
|
||||
if (status != RuntimeSetPositionMoverPreparationStatus.Prepared)
|
||||
{
|
||||
if (status.IsRetryable())
|
||||
{
|
||||
RetainPending(setPosition, new Pending
|
||||
{
|
||||
Record = record,
|
||||
Token = token,
|
||||
Route = route,
|
||||
AwaitingCommitWake = false,
|
||||
PositionEventOwed = false,
|
||||
Portal = portal,
|
||||
});
|
||||
LogPortalArrivalAttempt(
|
||||
RuntimeAcceptedPositionExecutionStatus.Contention,
|
||||
portal,
|
||||
record.FullCellId);
|
||||
return RuntimeAcceptedPositionExecutionStatus.Contention;
|
||||
}
|
||||
|
||||
CancelToken(setPosition, token);
|
||||
LogPortalArrivalAttempt(
|
||||
RuntimeAcceptedPositionExecutionStatus.Rejected,
|
||||
portal,
|
||||
record.FullCellId);
|
||||
return RuntimeAcceptedPositionExecutionStatus.Rejected;
|
||||
}
|
||||
|
||||
switch (outcome.Status)
|
||||
{
|
||||
case RuntimeSetPositionStatus.CommittedHostAcknowledgementPending:
|
||||
ReconcileAndAcknowledgePortal(record, route, portal);
|
||||
return RuntimeAcceptedPositionExecutionStatus.Committed;
|
||||
|
||||
case RuntimeSetPositionStatus.DeferredCell:
|
||||
// D-T2.4: a park should be rare (the destination was already
|
||||
// centered by the host before submit), but must never leak —
|
||||
// same drain-stale-Withdraw-then-watch shape as the force arm.
|
||||
while (setPosition.TryPeekProjection(
|
||||
out RuntimePlacementProjectionSnapshot parked)
|
||||
&& parked.Token.Entity == token.Entity
|
||||
&& parked.Kind is RuntimePlacementProjectionKind.Withdraw)
|
||||
{
|
||||
if (!setPosition.AcknowledgeProjection(parked.Token))
|
||||
break;
|
||||
}
|
||||
if (!setPosition.WatchPlacementCompletion(token))
|
||||
{
|
||||
CancelToken(setPosition, token);
|
||||
LogPortalArrivalAttempt(
|
||||
RuntimeAcceptedPositionExecutionStatus.Rejected,
|
||||
portal,
|
||||
record.FullCellId);
|
||||
return RuntimeAcceptedPositionExecutionStatus.Rejected;
|
||||
}
|
||||
RetainPending(setPosition, new Pending
|
||||
{
|
||||
Record = record,
|
||||
Token = token,
|
||||
Route = route,
|
||||
AwaitingCommitWake = true,
|
||||
PositionEventOwed = false,
|
||||
Portal = portal,
|
||||
});
|
||||
LogPortalArrivalAttempt(
|
||||
RuntimeAcceptedPositionExecutionStatus.DeferredCell,
|
||||
portal,
|
||||
record.FullCellId);
|
||||
return RuntimeAcceptedPositionExecutionStatus.DeferredCell;
|
||||
|
||||
default:
|
||||
// Rejected/Cancelled — authority moved out from under this
|
||||
// operation; the body never moved. Unlike the force arm,
|
||||
// retail's teleport branch has no unconditional ack to send,
|
||||
// so there is nothing left to do here.
|
||||
CancelToken(setPosition, token);
|
||||
LogPortalArrivalAttempt(
|
||||
RuntimeAcceptedPositionExecutionStatus.Rejected,
|
||||
portal,
|
||||
record.FullCellId);
|
||||
return RuntimeAcceptedPositionExecutionStatus.Rejected;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R8 review fix (2026-08-05): D-T8 specified "one line per
|
||||
/// portal-arrival ATTEMPT", but the first pass logged only from
|
||||
/// <see cref="ReconcileAndAcknowledgePortal"/> — reached solely on
|
||||
/// <c>Committed</c> — so every refusal was invisible under the gate's
|
||||
/// own pinned <c>ACDREAM_PROBE_LOCAL_TELEPORT</c> env var (the graphical
|
||||
/// refusal path logged under the DIFFERENT <c>ACDREAM_PROBE_TELEPORT</c>,
|
||||
/// and headless logged nothing at all). Every non-terminal/refusal exit
|
||||
/// from <see cref="TryExecuteAcceptedPortalArrival"/> and
|
||||
/// <see cref="SubmitAndResolvePortal"/> now emits through this one
|
||||
/// helper; the richer hookTail/leash/autorun facts remain
|
||||
/// <see cref="ReconcileAndAcknowledgePortal"/>'s own line on the
|
||||
/// <c>Committed</c> path, since those three booleans are meaningless
|
||||
/// before a commit.
|
||||
/// </summary>
|
||||
private static void LogPortalArrivalAttempt(
|
||||
RuntimeAcceptedPositionExecutionStatus status,
|
||||
in RuntimePortalPlacementAuthority portal,
|
||||
uint resolvedCell)
|
||||
{
|
||||
PhysicsDiagnostics.LogLocalTeleportArrival(
|
||||
cause: "portal",
|
||||
placementStatus: status.ToString(),
|
||||
portalGeneration: portal.RevealGeneration,
|
||||
teleportSequence: portal.TeleportSequence,
|
||||
destinationCell: portal.Projection.DestinationCell,
|
||||
resolvedCell: resolvedCell,
|
||||
hookTailRan: false,
|
||||
leashArmed: false,
|
||||
autorunCancelled: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A2/D-T2.4 review fix (2026-08-05): treats a portal authority as
|
||||
/// current when no re-validation predicate was wired (today's
|
||||
/// unconditional behaviour, preserved for fixtures that never park), and
|
||||
/// otherwise defers to <see cref="_isPortalAuthorityCurrent"/> — which
|
||||
/// production wires to the SAME
|
||||
/// <c>RuntimeWorldTransitState.CanPlacePortalDestination</c> query the
|
||||
/// App/headless Place edge itself uses.
|
||||
/// </summary>
|
||||
private bool IsPortalAuthorityCurrent(
|
||||
in RuntimePortalPlacementAuthority portal) =>
|
||||
_isPortalAuthorityCurrent is null || _isPortalAuthorityCurrent(portal);
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 3: the committed-portal-placement controller-local
|
||||
/// reconciliation and outbound tail. Runs
|
||||
/// <see cref="PlayerMovementController.CommitCanonicalTeleportFrame"/>
|
||||
/// (the re-homed <c>SetPositionCore</c> duties, D-T3), then the
|
||||
/// <c>PlayerTeleported</c> port (<c>CommandInterpreter::PlayerTeleported</c>
|
||||
/// @0x006B32B0 = <c>SetAutoRun(0,1)</c> + <c>SendMovementEvent</c>) — two
|
||||
/// named behaviour changes versus the deleted App/Headless placement
|
||||
/// authorities: autorun now cancels on portal arrival, and exactly one
|
||||
/// movement-event refresh goes out (never an <c>AutonomousPosition</c> —
|
||||
/// the route's <c>SendPositionImmediately</c> is always false).
|
||||
///
|
||||
/// <para>
|
||||
/// A4 review fix (2026-08-05): <paramref name="route"/>'s
|
||||
/// <c>ZeroVelocity</c>/<c>ConstrainPhase</c>/<c>TeleportHookPhase</c> are
|
||||
/// now READ, not assumed — <see cref="RuntimeAuthoritativePositionRoute.RunsTeleportHook"/>
|
||||
/// gates whether the hook tail runs at all, and its
|
||||
/// <c>ZeroVelocity</c>/<see cref="RuntimeAuthoritativePositionRoute.ConstrainAfterRouting"/>
|
||||
/// drive <c>CommitCanonicalTeleportFrame</c>'s two conditional duties.
|
||||
/// The LocalPlayer-teleport branch's values are unchanged today
|
||||
/// (<c>AfterPositionOperation</c>/<c>AfterPositionOperation</c>/<c>true</c>),
|
||||
/// so this is purely a wiring correction: a future classifier edit now
|
||||
/// changes this method's behaviour instead of silently disagreeing with
|
||||
/// it, and the contract's own §8 item 11 sabotage (force the classifier
|
||||
/// onto <c>ConstrainPhase.None</c> — the leash must not re-arm) can
|
||||
/// finally fail as designed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void ReconcileAndAcknowledgePortal(
|
||||
RuntimeEntityRecord record,
|
||||
in RuntimeAuthoritativePositionRoute route,
|
||||
in RuntimePortalPlacementAuthority portal)
|
||||
{
|
||||
// B1 review fix (2026-08-05): this method is called ONLY from the
|
||||
// two sites that just observed Runtime's canonical
|
||||
// CommittedHostAcknowledgementPending outcome for THIS portal
|
||||
// authority (SubmitAndResolvePortal's first-attempt commit and
|
||||
// Advance's re-validated deferred wake) — so the commit fact is
|
||||
// true here regardless of whether the two guards below decline the
|
||||
// REST of this method's App-level suffix work. Latching it FIRST,
|
||||
// unconditionally, is what lets TryConsumePortalCommit replace the
|
||||
// unsound PendingCount==0 inference both host gates used to make.
|
||||
_lastCommittedPortal = (portal.RevealGeneration, portal.TeleportSequence);
|
||||
if (record.ServerGuid != _localPlayerServerGuid())
|
||||
return;
|
||||
if (_localController() is not { } controller)
|
||||
return;
|
||||
// N4 review fix (2026-08-05): the frame/cell/stop/input-reset/clock
|
||||
// commit runs UNCONDITIONALLY (retail's SetPositionInternal
|
||||
// @0x00515330 has no hook-phase gate); only the UnStick/UnConstrain/
|
||||
// re-arm tail inside it is conditioned on the hook phase, via
|
||||
// runTeleportHookTail. Previously this whole call was skipped when
|
||||
// RunsTeleportHook was false, which would have silently dropped the
|
||||
// render-root UpdateCellId publish too (the doorway-FLAP class) the
|
||||
// day a route ever sets TeleportHookPhase.None — today's portal
|
||||
// route always sets a non-None phase, so this is a structural fix
|
||||
// with no live behavior change yet.
|
||||
bool hookTailRan = route.RunsTeleportHook;
|
||||
controller.CommitCanonicalTeleportFrame(
|
||||
zeroVelocity: route.ZeroVelocity,
|
||||
rearmConstraintLeash: route.ConstrainAfterRouting,
|
||||
runTeleportHookTail: hookTailRan);
|
||||
bool autorunCancelled = _localMovementState()?.CancelAutoRun() ?? false;
|
||||
// R7 review fix (2026-08-05): retail CommandInterpreter::SendMovementEvent
|
||||
// @0x006B4680 (PlayerTeleported's tail-jump) gates on TWO facts — a
|
||||
// non-null raw motion state (TryGetOutboundPosition/TryGetOutboundMotion
|
||||
// already cover that) AND `autonomy_level != 0`. This call was
|
||||
// unconditional. This is route 3's OWN call site only —
|
||||
// LocalPlayerOutboundController.TrySendMovement is shared with
|
||||
// route 2's DIFFERENT retail function
|
||||
// (CommandInterpreter::SendPositionEvent) and is not touched.
|
||||
//
|
||||
// Known approximation, filed AP-144 (2026-08-05, R7 round-3 review —
|
||||
// CLAUDE.md's register rule is binding, not an implementer's call):
|
||||
// this class only has RuntimeCharacterState.UsePositionFromServer in
|
||||
// scope (`AutonomyLevel != FullAutonomyLevel(2)`, retail's
|
||||
// `autonomy_level != 2`), not the raw AutonomyLevel — so
|
||||
// `!UsePositionFromServer` sends only when AutonomyLevel==2,
|
||||
// whereas retail's actual gate (`autonomy_level != 0`) ALSO sends
|
||||
// at AutonomyLevel==1. The two agree everywhere except that one mid
|
||||
// level, currently unreachable because TrySetAutonomyLevel has zero
|
||||
// production callers. Retire by threading the raw AutonomyLevel
|
||||
// through this constructor (and both host compositions) and gating
|
||||
// on `!= 0` directly instead of reusing UsePositionFromServer.
|
||||
if (!_usePositionFromServer())
|
||||
{
|
||||
_localPlayerOutbound.TrySendMovement(
|
||||
_session(),
|
||||
controller,
|
||||
controller.CapturePresentationResult());
|
||||
}
|
||||
|
||||
// D-T8 probe (temporary): confirms the reconcile suffix actually
|
||||
// ran its three named duties on THIS commit, not just that the
|
||||
// commit was reached. R3 review fix: the leash observable is
|
||||
// ConstraintManager.IsConstrained ("has a leash"), not
|
||||
// IsFullyConstrained ("has strained past 90% of it") — the latter
|
||||
// reads false immediately after ConstrainTo re-anchors at distance
|
||||
// 0, so every committed arrival printed leash=unarmed as coded.
|
||||
PhysicsDiagnostics.LogLocalTeleportArrival(
|
||||
cause: "portal",
|
||||
placementStatus: "Committed",
|
||||
portalGeneration: portal.RevealGeneration,
|
||||
teleportSequence: portal.TeleportSequence,
|
||||
destinationCell: portal.Projection.DestinationCell,
|
||||
resolvedCell: record.FullCellId,
|
||||
hookTailRan: hookTailRan,
|
||||
leashArmed: controller.PositionManager?.Constraint?.IsConstrained
|
||||
?? false,
|
||||
autorunCancelled: autorunCancelled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Host cadence pump: resolves a parked DeferredCell operation once its
|
||||
/// destination landblock's collision generation eventually commits it
|
||||
|
|
@ -461,6 +978,42 @@ public sealed class RuntimeAcceptedPositionDriveController
|
|||
// Raced against a concurrent consumer; retry next pump.
|
||||
return;
|
||||
}
|
||||
// C4 route 3 (trap T7): a portal pending never enters the
|
||||
// force funnel — SettlePending's _newestForce re-issue
|
||||
// decision belongs to the force arm only. The deferred
|
||||
// commit's own reconciliation is the portal wake's entire
|
||||
// terminal action.
|
||||
if (pending.Portal.Present)
|
||||
{
|
||||
_pending = null;
|
||||
// A2/D-T2.4 re-validation: RetryDeferred already moved
|
||||
// the body (asynchronously, outside this class's
|
||||
// control — nothing here can prevent that). What this
|
||||
// CAN prevent is running the reconcile/ack suffix
|
||||
// against a reveal that ended or was superseded while
|
||||
// the park sat outstanding, which would otherwise
|
||||
// publish a Place receipt naming a dead portal
|
||||
// authority (architecture review A2's FIFO-wedge
|
||||
// shape).
|
||||
if (!IsPortalAuthorityCurrent(pending.Portal))
|
||||
{
|
||||
PhysicsDiagnostics.LogLocalTeleportArrival(
|
||||
cause: "portal",
|
||||
placementStatus: "AbandonedAtWake",
|
||||
portalGeneration: pending.Portal.RevealGeneration,
|
||||
teleportSequence: pending.Portal.TeleportSequence,
|
||||
destinationCell:
|
||||
pending.Portal.Projection.DestinationCell,
|
||||
resolvedCell: pending.Record.FullCellId,
|
||||
hookTailRan: false,
|
||||
leashArmed: false,
|
||||
autorunCancelled: false);
|
||||
return;
|
||||
}
|
||||
ReconcileAndAcknowledgePortal(
|
||||
pending.Record, pending.Route, pending.Portal);
|
||||
return;
|
||||
}
|
||||
// Retail order: the deferred commit's own reconciliation and
|
||||
// ack come first, THEN the funnel decides whether a newer
|
||||
// accepted force is still owed a placement (B1).
|
||||
|
|
@ -480,9 +1033,39 @@ public sealed class RuntimeAcceptedPositionDriveController
|
|||
}
|
||||
|
||||
// The watch died — most likely a subsequent accepted Position's
|
||||
// merge-time Forget. The funnel owns what happens next, including
|
||||
// this packet's still-unsent position event (retail acks whether
|
||||
// or not the placement took — see SettlePending).
|
||||
// merge-time Forget. A portal pending owes no re-issue and no
|
||||
// ack (SendPositionImmediately is always false for the portal
|
||||
// route), so it simply clears — matching the D-T5 refusal shape
|
||||
// (nothing mutates; the transit's own cancellation/supersession
|
||||
// machinery is the authority on what happens next).
|
||||
//
|
||||
// B1/N1 review fix (2026-08-05): this is the drive's own
|
||||
// documented MODAL park outcome (ACE's 5-10 Hz broadcast Forgets
|
||||
// any park surviving one interval — "the exact far-destination
|
||||
// case the park exists to serve"), not a corner case. It does
|
||||
// NOT set _lastCommittedPortal — nothing committed — so the host
|
||||
// gate's TryConsumePortalCommit correctly reports "not yet" and
|
||||
// either re-attempts fresh or converges through the transit's
|
||||
// own cancellation, instead of the old PendingCount==0
|
||||
// inference latching a false "committed".
|
||||
if (pending.Portal.Present)
|
||||
{
|
||||
_pending = null;
|
||||
PhysicsDiagnostics.LogLocalTeleportArrival(
|
||||
cause: "portal",
|
||||
placementStatus: "WatchDied",
|
||||
portalGeneration: pending.Portal.RevealGeneration,
|
||||
teleportSequence: pending.Portal.TeleportSequence,
|
||||
destinationCell: pending.Portal.Projection.DestinationCell,
|
||||
resolvedCell: pending.Record.FullCellId,
|
||||
hookTailRan: false,
|
||||
leashArmed: false,
|
||||
autorunCancelled: false);
|
||||
return;
|
||||
}
|
||||
// The funnel owns what happens next, including this packet's
|
||||
// still-unsent position event (retail acks whether or not the
|
||||
// placement took — see SettlePending).
|
||||
SettlePending(
|
||||
pending.Record,
|
||||
pending.Token,
|
||||
|
|
@ -493,14 +1076,54 @@ public sealed class RuntimeAcceptedPositionDriveController
|
|||
|
||||
if (setPosition.IsPlacementCurrent(pending.Token))
|
||||
{
|
||||
_ = SubmitAndResolve(pending.Record, pending.Token, pending.Route);
|
||||
if (pending.Portal.Present
|
||||
&& !IsPortalAuthorityCurrent(pending.Portal))
|
||||
{
|
||||
// A2/D-T2.4: unlike the AwaitingCommitWake branch above,
|
||||
// this retry has NOT submitted yet — re-validating here
|
||||
// genuinely prevents a stale commit rather than only
|
||||
// suppressing its suffix.
|
||||
_pending = null;
|
||||
CancelToken(setPosition, pending.Token);
|
||||
PhysicsDiagnostics.LogLocalTeleportArrival(
|
||||
cause: "portal",
|
||||
placementStatus: "AbandonedAtWake",
|
||||
portalGeneration: pending.Portal.RevealGeneration,
|
||||
teleportSequence: pending.Portal.TeleportSequence,
|
||||
destinationCell: pending.Portal.Projection.DestinationCell,
|
||||
resolvedCell: pending.Record.FullCellId,
|
||||
hookTailRan: false,
|
||||
leashArmed: false,
|
||||
autorunCancelled: false);
|
||||
return;
|
||||
}
|
||||
_ = pending.Portal.Present
|
||||
? SubmitAndResolvePortal(
|
||||
pending.Record, pending.Token, pending.Route, pending.Portal)
|
||||
: SubmitAndResolve(pending.Record, pending.Token, pending.Route);
|
||||
return;
|
||||
}
|
||||
|
||||
// The prepare-retry operation died the same way. (A re-issue retry
|
||||
// marker also lands here, carrying PositionEventOwed: false — its
|
||||
// packet's placement was never begun, so its ack belongs to the
|
||||
// eventual re-issue's terminal outcome.)
|
||||
// The prepare-retry operation died the same way. B1/N1: no commit,
|
||||
// no _lastCommittedPortal write — see the watch-died branch above.
|
||||
if (pending.Portal.Present)
|
||||
{
|
||||
_pending = null;
|
||||
PhysicsDiagnostics.LogLocalTeleportArrival(
|
||||
cause: "portal",
|
||||
placementStatus: "PrepareRetryLost",
|
||||
portalGeneration: pending.Portal.RevealGeneration,
|
||||
teleportSequence: pending.Portal.TeleportSequence,
|
||||
destinationCell: pending.Portal.Projection.DestinationCell,
|
||||
resolvedCell: pending.Record.FullCellId,
|
||||
hookTailRan: false,
|
||||
leashArmed: false,
|
||||
autorunCancelled: false);
|
||||
return;
|
||||
}
|
||||
// (A re-issue retry marker also lands here, carrying
|
||||
// PositionEventOwed: false — its packet's placement was never begun,
|
||||
// so its ack belongs to the eventual re-issue's terminal outcome.)
|
||||
SettlePending(
|
||||
pending.Record,
|
||||
pending.Token,
|
||||
|
|
|
|||
|
|
@ -39,9 +39,18 @@ public interface IRuntimeDirectWorldProjection
|
|||
|
||||
void BeginTeleport();
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 3 (D-T6): <paramref name="portal"/> is the SAME host token
|
||||
/// <see cref="RuntimeLiveEntitySessionController.TryCompletePortal"/>
|
||||
/// just registered via <c>TryRegisterHostProjection</c> — the producer's
|
||||
/// generation/sequence/projection are all already in scope here, so no
|
||||
/// new <c>WorldRevealCoordinator</c>-style exposure is needed on this
|
||||
/// side either.
|
||||
/// </summary>
|
||||
RuntimeDestinationReadiness PrepareDestination(
|
||||
long revealGeneration,
|
||||
RuntimeTeleportDestination destination);
|
||||
RuntimeTeleportDestination destination,
|
||||
RuntimeWorldHostProjectionToken portal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -490,6 +499,43 @@ public sealed class RuntimeLiveEntitySessionController
|
|||
acknowledgeProjection: null,
|
||||
out _);
|
||||
|
||||
/// <summary>
|
||||
/// A1/A3 review fix (2026-08-05): the generation/destination/projection
|
||||
/// of an accepted portal reveal that registered its host projection but
|
||||
/// has not yet actually placed the local player. Headless is
|
||||
/// message-driven, not per-frame — <see cref="TryCompletePortal"/> used
|
||||
/// to run the ENTIRE completion sequence (readiness ack, materialized
|
||||
/// ack, complete, LoginComplete, EndTeleport) unconditionally in one
|
||||
/// synchronous call, discarding the canonical portal arm's own status
|
||||
/// (architecture review A3). A <c>DeferredCell</c> park is a NORMAL
|
||||
/// headless outcome — <see cref="IRuntimeDirectWorldProjection.CenterOnAcceptedForcePosition"/>'s
|
||||
/// doc explains why the narrow collision window makes a park real
|
||||
/// rather than a dead end — so this field lets
|
||||
/// <see cref="PumpPortalCompletion"/> retry on the host's own per-tick
|
||||
/// cadence (<c>HeadlessSessionHost.Tick</c>) instead of either
|
||||
/// completing a materialization that never happened or throwing on
|
||||
/// every ordinary "destination not resident yet" park.
|
||||
/// </summary>
|
||||
private (long Generation,
|
||||
RuntimeTeleportDestination Destination,
|
||||
RuntimeWorldHostProjectionToken Projection)? _pendingPortalCompletion;
|
||||
|
||||
/// <summary>
|
||||
/// B4 review fix (2026-08-05): the retry count for the CURRENT
|
||||
/// <see cref="_pendingPortalCompletion"/>, reset whenever a NEW portal
|
||||
/// begins. Graphical's equivalent wait has a user-visible cue (AD-2's
|
||||
/// centered wait state) when a park runs long; headless had neither a
|
||||
/// cue, a bound, nor a log — an indefinitely stuck park (a destination
|
||||
/// landblock whose collision generation never publishes) was silent and
|
||||
/// undiagnosable. This does not make the retry fatal — K4's 30-session
|
||||
/// endurance profile must survive a legitimately slow-publishing
|
||||
/// landblock — it only makes a stuck park OBSERVABLE via periodic log
|
||||
/// lines instead of running forever in silence.
|
||||
/// </summary>
|
||||
private int _pendingPortalCompletionRetryCount;
|
||||
|
||||
private const int PendingPortalCompletionLogInterval = 100;
|
||||
|
||||
private void TryCompletePortal()
|
||||
{
|
||||
RuntimeWorldTransitState transit = _runtime.TransitOwner;
|
||||
|
|
@ -517,11 +563,39 @@ public sealed class RuntimeLiveEntitySessionController
|
|||
projection,
|
||||
RuntimeWorldHostAcknowledgementStage.ProjectionRegistered);
|
||||
|
||||
_pendingPortalCompletion = (generation, destination, projection);
|
||||
_pendingPortalCompletionRetryCount = 0;
|
||||
TryAdvancePortalCompletion();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A1/A3 review fix: the retryable second half of
|
||||
/// <see cref="TryCompletePortal"/>. Attempts the canonical placement
|
||||
/// (via <see cref="_worldProjection"/>, which owns the drive controller)
|
||||
/// exactly once per call; if it has not committed yet, this returns
|
||||
/// having mutated nothing beyond what the attempt itself did (a
|
||||
/// DeferredCell park, safely retryable by construction — see
|
||||
/// <see cref="HeadlessSessionWorldProjection.PrepareDestination"/>'s own
|
||||
/// doc), and <see cref="PumpPortalCompletion"/> calls this again on the
|
||||
/// next host tick. Once <c>IsCollisionReady</c> comes back true — which
|
||||
/// only happens after a genuine <c>Committed</c> status — the full
|
||||
/// readiness/materialized/complete/LoginComplete/EndTeleport sequence
|
||||
/// runs exactly as before this fix, unconditionally, in one call.
|
||||
/// </summary>
|
||||
private void TryAdvancePortalCompletion()
|
||||
{
|
||||
if (_pendingPortalCompletion is not { } pending)
|
||||
return;
|
||||
(long generation, RuntimeTeleportDestination destination,
|
||||
RuntimeWorldHostProjectionToken projection) = pending;
|
||||
|
||||
RuntimeWorldTransitState transit = _runtime.TransitOwner;
|
||||
bool indoor = (destination.CellId & 0xFFFFu) >= 0x0100u;
|
||||
RuntimeDestinationReadiness readiness =
|
||||
_worldProjection?.PrepareDestination(
|
||||
generation,
|
||||
destination)
|
||||
destination,
|
||||
projection)
|
||||
?? new RuntimeDestinationReadiness(
|
||||
generation,
|
||||
destination.CellId,
|
||||
|
|
@ -531,6 +605,32 @@ public sealed class RuntimeLiveEntitySessionController
|
|||
IsRenderNeighborhoodReady: true,
|
||||
AreCompositeTexturesReady: true,
|
||||
IsCollisionReady: true);
|
||||
if (!readiness.IsCollisionReady)
|
||||
{
|
||||
// Still parked - PrepareDestination attempted (or is waiting on
|
||||
// an outstanding DeferredCell wake) and has not committed yet.
|
||||
// Nothing acknowledged, nothing completed; PumpPortalCompletion
|
||||
// retries next tick.
|
||||
//
|
||||
// B4 review fix: periodic diagnostic so an indefinitely-stuck
|
||||
// park is observable instead of silent. Not bounded to a throw -
|
||||
// a slow-publishing landblock is a legitimate transient this
|
||||
// host must ride out (N3's lesson: don't make a transient
|
||||
// fatal).
|
||||
_pendingPortalCompletionRetryCount++;
|
||||
if (_pendingPortalCompletionRetryCount % PendingPortalCompletionLogInterval == 0)
|
||||
{
|
||||
_log(
|
||||
$"headless: portal completion still parked after "
|
||||
+ $"{_pendingPortalCompletionRetryCount} retries "
|
||||
+ $"generation={generation} cell=0x{destination.CellId:X8}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingPortalCompletion = null;
|
||||
_pendingPortalCompletionRetryCount = 0;
|
||||
|
||||
if (!transit.AcknowledgeDestinationReadiness(
|
||||
readiness))
|
||||
{
|
||||
|
|
@ -581,6 +681,14 @@ public sealed class RuntimeLiveEntitySessionController
|
|||
+ $"cell=0x{destination.CellId:X8}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A1/A3 review fix: called from <c>HeadlessSessionHost.Tick</c>
|
||||
/// alongside <c>HeadlessSessionWorldProjection.PumpFirstEntry</c> —
|
||||
/// retries a parked portal completion on the host's own per-tick
|
||||
/// cadence. A no-op whenever nothing is pending.
|
||||
/// </summary>
|
||||
public void PumpPortalCompletion() => TryAdvancePortalCompletion();
|
||||
|
||||
private static void Acknowledge(
|
||||
RuntimeWorldTransitState transit,
|
||||
RuntimeWorldHostProjectionToken projection,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue