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>
706 lines
31 KiB
C#
706 lines
31 KiB
C#
using System.Numerics;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Runtime.Entities;
|
|
using AcDream.Runtime.Gameplay;
|
|
using AcDream.Runtime.World;
|
|
|
|
namespace AcDream.Runtime.Session;
|
|
|
|
public interface IRuntimeDirectWorldProjection
|
|
{
|
|
void ProjectSpawn(
|
|
RuntimeEntityRecord record,
|
|
bool isLocalPlayer);
|
|
|
|
void ProjectPosition(
|
|
RuntimeEntityRecord record,
|
|
bool isLocalPlayer,
|
|
PositionTimestampDisposition disposition);
|
|
|
|
/// <summary>
|
|
/// R2 review fix (2026-08-03): a ForcePosition on the local player is
|
|
/// dispatched directly to <see cref="RuntimeAcceptedPositionDriveController"/>
|
|
/// and never reaches <see cref="ProjectPosition"/> at all, so THIS is
|
|
/// where a host that keeps a narrow collision/streaming window (the
|
|
/// deleted <c>HeadlessSessionWorldProjection.BlipLocalPlayer</c>'s own
|
|
/// <c>_collision.CenterOn</c> call) re-centers on the destination BEFORE
|
|
/// the drive controller submits — establishing that the destination's
|
|
/// collision generation is one this host's window can ever publish is a
|
|
/// precondition for a <c>DeferredCell</c> park to be a real park rather
|
|
/// than a dead end (see <c>RuntimeAcceptedPositionDriveController.Advance</c>'s
|
|
/// R1 doc comment). A host with no narrow window (the graphical host,
|
|
/// whose landblock streaming already follows the accepted position via
|
|
/// <c>LiveEntityInboundAuthorityGate.ObserveAcceptedLocalPosition</c>) is
|
|
/// a no-op here.
|
|
/// </summary>
|
|
void CenterOnAcceptedForcePosition(RuntimeEntityRecord record);
|
|
|
|
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,
|
|
RuntimeWorldHostProjectionToken portal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Presentation-free inbound entity route for a direct Runtime host. It
|
|
/// applies the same canonical identity, timestamp, object-table, and transit
|
|
/// owners used by the graphical route without constructing App hydration,
|
|
/// rendering, animation, or effect projections.
|
|
/// </summary>
|
|
public sealed class RuntimeLiveEntitySessionController
|
|
{
|
|
private readonly GameRuntime _runtime;
|
|
private readonly WorldSession _session;
|
|
private readonly Action<string> _log;
|
|
private readonly IRuntimeDirectWorldProjection? _worldProjection;
|
|
/// <summary>
|
|
/// C4 route 2 (2026-08-03): the headless accepted-Position drive
|
|
/// controller. Owns its own outbound-ack collaborator internally; the
|
|
/// ForcePosition + manual <c>LocalPlayerOutboundController.SendImmediatePosition</c>
|
|
/// pair this class used to drive directly is retired (the deleted
|
|
/// <c>HeadlessSessionWorldProjection.BlipLocalPlayer</c>).
|
|
/// </summary>
|
|
private readonly RuntimeAcceptedPositionDriveController? _acceptedPositionDrive;
|
|
private bool _initialLoginCompleteSent;
|
|
// A6 (architecture review): D5's ResolveAndCommitChildAttachment ran on
|
|
// every accepted spawn and every ParentEvent, allocating three
|
|
// this-capturing closures per call. These capture nothing per-call
|
|
// (only `this`), so cache them once instead of per invocation.
|
|
private readonly Func<uint, bool> _isChildGuidKnown;
|
|
private readonly Func<uint, ushort?> _resolveParentInstance;
|
|
private readonly Func<ParentEvent.Parsed, bool> _acceptParentEvent;
|
|
|
|
public RuntimeLiveEntitySessionController(
|
|
GameRuntime runtime,
|
|
WorldSession session,
|
|
Action<string>? log = null,
|
|
IRuntimeDirectWorldProjection? worldProjection = null,
|
|
RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null)
|
|
{
|
|
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
|
_session = session ?? throw new ArgumentNullException(nameof(session));
|
|
_log = log ?? (_ => { });
|
|
_worldProjection = worldProjection;
|
|
_acceptedPositionDrive = acceptedPositionDrive;
|
|
_isChildGuidKnown = guid => Entities.Entities.TryGetSnapshot(guid, out _);
|
|
_resolveParentInstance = guid =>
|
|
Entities.Entities.TryGetSnapshot(guid, out WorldSession.EntitySpawn spawn)
|
|
? spawn.InstanceSequence
|
|
: null;
|
|
_acceptParentEvent = candidate => Entities.TryApplyParent(
|
|
candidate,
|
|
acknowledgeProjection: null,
|
|
out _);
|
|
}
|
|
|
|
public LiveEntitySessionSink CreateSink() => new(
|
|
OnSpawned,
|
|
OnDeleted,
|
|
OnPickedUp,
|
|
OnMotionUpdated,
|
|
OnPositionUpdated,
|
|
OnVectorUpdated,
|
|
OnStateUpdated,
|
|
OnParentUpdated,
|
|
OnTeleportStarted,
|
|
OnAppearanceUpdated,
|
|
_ => { },
|
|
_ => { });
|
|
|
|
private RuntimeEntityObjectLifetime Entities =>
|
|
_runtime.EntityObjects;
|
|
|
|
private void OnSpawned(WorldSession.EntitySpawn spawn)
|
|
{
|
|
// C3c route-8 flip: every direct-host Create enters the SAME initial
|
|
// residence lease graphical route 1 uses; the conductor drive (via
|
|
// IRuntimeDirectWorldProjection.ProjectSpawn and the host's pump)
|
|
// owns mover preparation, body/controller construction, placement,
|
|
// and the FIFO drain from here.
|
|
//
|
|
// C3c-R1 review R3: a CONTENT-LESS host (a validated-legal headless
|
|
// configuration — HeadlessConfigurationLoader.ValidateContent
|
|
// accepts a null process.content) constructs no world projection
|
|
// and therefore no first-entry drive; opening a residence with no
|
|
// drive to pump it would park every Create (and every position/
|
|
// state packet queued behind its pending residence) forever. That
|
|
// configuration keeps the exact pre-flip legacy registration:
|
|
// presentation-free RegisterEntity plus the direct accepted-frame
|
|
// commit below. C4/C5 revisit: unify once the direct-host conductor
|
|
// drive no longer requires prepared content.
|
|
RuntimeEntityRegistrationResult registration = _worldProjection is null
|
|
? Entities.RegisterEntity(spawn)
|
|
: Entities.RegisterEntityWithInitialResidence(
|
|
spawn,
|
|
isLocalPlayer: spawn.Guid
|
|
== _runtime.PlayerIdentity.ServerGuid);
|
|
if (registration.Canonical is not { } canonical)
|
|
return;
|
|
|
|
ulong integrationVersion = canonical.CreateIntegrationVersion;
|
|
bool applied = Entities.ApplyAcceptedSpawn(
|
|
canonical,
|
|
integrationVersion,
|
|
canonical.Snapshot,
|
|
replaceGeneration:
|
|
registration.Inbound.Disposition
|
|
is CreateObjectTimestampDisposition.NewGeneration);
|
|
if (applied)
|
|
{
|
|
_worldProjection?.ProjectSpawn(
|
|
canonical,
|
|
canonical.ServerGuid
|
|
== _runtime.PlayerIdentity.ServerGuid);
|
|
// D5: this spawn may be the parent a standalone ParentEvent
|
|
// already named before its own CreateObject arrived.
|
|
RetryChildrenWaitingForParent(canonical.ServerGuid);
|
|
if (_worldProjection is null
|
|
&& canonical.ServerGuid
|
|
== _runtime.PlayerIdentity.ServerGuid
|
|
&& !_initialLoginCompleteSent)
|
|
{
|
|
// A content-less direct host has no first-entry placement
|
|
// conductor. Its accepted local Create is therefore its
|
|
// truthful terminal admission edge.
|
|
_initialLoginCompleteSent = true;
|
|
_session.SendGameAction(GameActionLoginComplete.Build());
|
|
}
|
|
}
|
|
}
|
|
|
|
private void OnDeleted(DeleteObject.Parsed delete)
|
|
{
|
|
if (delete.Guid == _runtime.PlayerIdentity.ServerGuid
|
|
|| !Entities.TryAcceptDelete(
|
|
delete,
|
|
isLocalPlayer: false,
|
|
removeRetainedObject: true,
|
|
out RuntimeEntityDeleteAcceptance acceptance))
|
|
{
|
|
return;
|
|
}
|
|
|
|
Entities.CompleteAcceptedDelete(acceptance);
|
|
if (acceptance.RetiredCanonical is { } retired)
|
|
{
|
|
Exception? failure = Entities.RetireCanonicalOnly(retired);
|
|
if (failure is not null)
|
|
throw failure;
|
|
}
|
|
}
|
|
|
|
private void OnPickedUp(PickupEvent.Parsed pickup) =>
|
|
_ = Entities.TryApplyPickup(
|
|
pickup,
|
|
acknowledgeProjection: null,
|
|
out _);
|
|
|
|
private void OnMotionUpdated(
|
|
WorldSession.EntityMotionUpdate update)
|
|
{
|
|
bool isLocal =
|
|
update.Guid == _runtime.PlayerIdentity.ServerGuid;
|
|
_ = Entities.TryApplyMotion(
|
|
update,
|
|
retainPayload: !isLocal || !update.IsAutonomous,
|
|
acknowledgeProjection: null,
|
|
out _,
|
|
out _);
|
|
}
|
|
|
|
private void OnPositionUpdated(
|
|
WorldSession.EntityPositionUpdate update)
|
|
{
|
|
bool isLocal =
|
|
update.Guid == _runtime.PlayerIdentity.ServerGuid;
|
|
PlayerMovementController? localController =
|
|
isLocal ? _runtime.MovementOwner.Controller : null;
|
|
bool known = Entities.TryApplyPosition(
|
|
update,
|
|
isLocal,
|
|
forcePositionRotation: localController?.BodyOrientation,
|
|
currentLocalVelocity: localController?.BodyVelocity,
|
|
acknowledgeProjection: null,
|
|
out PositionTimestampDisposition disposition,
|
|
out _,
|
|
out AcceptedPhysicsTimestamps timestamps);
|
|
if (!known
|
|
|| !isLocal
|
|
|| disposition is PositionTimestampDisposition.Rejected)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (disposition is PositionTimestampDisposition.Apply)
|
|
{
|
|
var position = update.Position;
|
|
var destination = new RuntimeTeleportDestination(
|
|
update.Guid,
|
|
update.InstanceSequence,
|
|
update.PositionSequence,
|
|
update.TeleportSequence,
|
|
update.ForcePositionSequence,
|
|
new Position(
|
|
position.LandblockId,
|
|
new Vector3(
|
|
position.PositionX,
|
|
position.PositionY,
|
|
position.PositionZ),
|
|
new Quaternion(
|
|
position.RotationX,
|
|
position.RotationY,
|
|
position.RotationZ,
|
|
position.RotationW)));
|
|
_runtime.TransitOwner.OfferTeleportDestination(
|
|
destination,
|
|
timestamps.TeleportAdvanced);
|
|
}
|
|
if (Entities.Entities.TryGetActive(
|
|
update.Guid,
|
|
out RuntimeEntityRecord record))
|
|
{
|
|
if (disposition is PositionTimestampDisposition.ForcePosition)
|
|
{
|
|
// R2 review fix (2026-08-03): re-center BEFORE submitting —
|
|
// see IRuntimeDirectWorldProjection.CenterOnAcceptedForcePosition's
|
|
// doc comment. This is what the deleted BlipLocalPlayer's own
|
|
// _collision.CenterOn call used to guarantee.
|
|
_worldProjection?.CenterOnAcceptedForcePosition(record);
|
|
|
|
// C4 route 2 (2026-08-03): the Runtime-owned accepted-
|
|
// Position execution seam replaces the deleted
|
|
// HeadlessSessionWorldProjection.BlipLocalPlayer + manual
|
|
// SendImmediatePosition pair. Canonical commit, controller
|
|
// reconciliation, and the outbound ack (an OUTPUT of the
|
|
// committed route, not a step alongside it) all run inside
|
|
// the call below.
|
|
RuntimeAcceptedPositionExecutionStatus forceStatus =
|
|
_acceptedPositionDrive?.TryExecuteAcceptedLocalPosition(
|
|
record,
|
|
update,
|
|
disposition,
|
|
timestamps,
|
|
timestamps.PreviousTeleport)
|
|
?? RuntimeAcceptedPositionExecutionStatus.NotApplicable;
|
|
if (forceStatus is RuntimeAcceptedPositionExecutionStatus
|
|
.NotApplicable)
|
|
{
|
|
// R3 review fix (2026-08-03): NotApplicable (e.g. an
|
|
// initial-Create residence still owns this record —
|
|
// route 1's job, or the login-window controller-null
|
|
// branch before route 1 has even published a
|
|
// controller) is NOT the terminal case the previous
|
|
// comment here claimed. ProjectPosition's own
|
|
// controller-null branch is the pre-existing legacy
|
|
// fallback this disposition always had — it must still
|
|
// run, exactly as every other disposition's fallback
|
|
// does below.
|
|
_worldProjection?.ProjectPosition(
|
|
record,
|
|
isLocalPlayer: true,
|
|
disposition);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_worldProjection?.ProjectPosition(
|
|
record,
|
|
isLocalPlayer: true,
|
|
disposition);
|
|
}
|
|
}
|
|
TryCompletePortal();
|
|
}
|
|
|
|
private void OnVectorUpdated(VectorUpdate.Parsed update) =>
|
|
_ = Entities.TryApplyVector(
|
|
update,
|
|
acknowledgeProjection: null,
|
|
out _);
|
|
|
|
private void OnStateUpdated(SetState.Parsed update) =>
|
|
_ = Entities.TryApplyState(
|
|
update,
|
|
acknowledgeProjection: null,
|
|
out _,
|
|
out _);
|
|
|
|
private void OnParentUpdated(ParentEvent.Parsed update)
|
|
{
|
|
Entities.Entities.ParentAttachments.Enqueue(update);
|
|
ResolveAndCommitChildAttachment(update.ChildGuid);
|
|
}
|
|
|
|
/// <summary>
|
|
/// C4 route 7 D5: the headless parent-realize drive. Resolves a queued
|
|
/// standalone <see cref="ParentEvent.Parsed"/> through the SAME staged
|
|
/// -> committed protocol the graphical
|
|
/// <c>EquippedChildRenderController.ResolveAndTryRealize</c> /
|
|
/// <c>PrepareAndTryRealize</c> pair runs —
|
|
/// <see cref="ParentAttachmentState.Resolve"/>, then
|
|
/// <see cref="RuntimeEntityObjectLifetime.TryCommitParent"/> ->
|
|
/// <see cref="ParentAttachmentState.CommitProjection"/> ->
|
|
/// <see cref="RuntimeEntityObjectLifetime.CommitAcceptedParentCellless"/>
|
|
/// (which carries D1's attach re-cell) — so a direct/no-window host
|
|
/// gets the same canonical child-cell commit the graphical host has
|
|
/// always had. Deliberately does NOT drive pose composition, the
|
|
/// render bucket, or <c>ValidateParentProjection</c>'s self-parenting
|
|
/// / part-array / <c>Setup.HoldingLocations</c> checks — see AP-143.
|
|
///
|
|
/// <para>
|
|
/// KNOWN GAP, stated rather than silently left implicit (retail-
|
|
/// conformance review R6): if <paramref name="childGuid"/> has a
|
|
/// PENDING initial-create residence when the relation resolves to
|
|
/// staged, <see cref="RuntimeEntityObjectLifetime.TryCommitParent"/>'s
|
|
/// gate (<c>InboundPhysicsStateController.TryCommitParent</c>'s
|
|
/// <c>gate.PositionTimestamp == positionSequence</c> check) is not yet
|
|
/// satisfied and this method returns <see langword="false"/>. Nothing
|
|
/// re-drives it: the residence executor's own parent-attach tail
|
|
/// (<c>RuntimeInitialCreateContinuationExecutor.CommitParentAttachment</c>)
|
|
/// deliberately does not commit the relation either — that has always
|
|
/// been the graphical host's job — and headless has no
|
|
/// <c>EquippedChildRenderController</c>-equivalent post-drain retry.
|
|
/// The relation stays staged and the child stays cell-less until SOME
|
|
/// other event re-invokes <see cref="ResolveAndCommitChildAttachment"/>
|
|
/// for the same child (a later ParentEvent, or a spawn naming the same
|
|
/// parent guid via <see cref="RetryChildrenWaitingForParent"/> — which
|
|
/// does not cover this case either, since the relation is already
|
|
/// staged, not unresolved). Not a regression (headless committed
|
|
/// nothing on this path before D5 existed), and the invariant-9
|
|
/// dormant-residence deferrals themselves are untouched — but a
|
|
/// headless ParentEvent arriving during a child's own pending initial
|
|
/// residence is NOT closed by this slice.
|
|
/// </para>
|
|
/// </summary>
|
|
private bool ResolveAndCommitChildAttachment(uint childGuid)
|
|
{
|
|
ParentAttachmentState relations = Entities.Entities.ParentAttachments;
|
|
relations.Resolve(
|
|
childGuid,
|
|
_isChildGuidKnown,
|
|
_resolveParentInstance,
|
|
_acceptParentEvent);
|
|
if (!relations.TryGetStagedProjection(
|
|
childGuid,
|
|
out ParentAttachmentRelation staged))
|
|
{
|
|
return false;
|
|
}
|
|
if (!Entities.Entities.TryGetActive(
|
|
childGuid,
|
|
out RuntimeEntityRecord canonical))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
ulong positionAuthorityVersion = canonical.PositionAuthorityVersion;
|
|
if (!Entities.TryCommitParent(staged, acknowledgeProjection: null, out _)
|
|
|| !relations.CommitProjection(staged))
|
|
{
|
|
return false;
|
|
}
|
|
bool committed = Entities.CommitAcceptedParentCellless(
|
|
canonical,
|
|
positionAuthorityVersion,
|
|
acknowledgeProjection: null);
|
|
if (committed && PhysicsDiagnostics.ProbeChildCellEnabled)
|
|
{
|
|
Console.WriteLine(FormattableString.Invariant(
|
|
$"[child-cell] parent=0x{staged.ParentGuid:X8} child=0x{canonical.ServerGuid:X8} new=0x{canonical.FullCellId:X8} cause=headless-attach"));
|
|
}
|
|
return committed;
|
|
}
|
|
|
|
/// <summary>
|
|
/// D5 companion: a ParentEvent can precede the parent's own CreateObject
|
|
/// (retail: the standalone parent handler queues by parent guid). Retry
|
|
/// every child waiting on the guid that just became addressable.
|
|
///
|
|
/// <para>
|
|
/// A6 (architecture review): unlike the graphical
|
|
/// <c>EquippedChildRenderController.RetryWaitingDescendants</c> →
|
|
/// <c>ParentAttachmentState.ChildrenWaitingForParent</c>, this drive's
|
|
/// OWN <c>Resolve</c>/<c>TryGetStagedProjection</c>/<c>CommitProjection</c>
|
|
/// sequence in <see cref="ResolveAndCommitChildAttachment"/> consumes a
|
|
/// relation out of <c>_stagedByChild</c> within the SAME synchronous
|
|
/// call it was staged in, for the ordinary case. **Correction (B1/B2,
|
|
/// round-3 review): this is NOT an absolute "never populates
|
|
/// _stagedByChild" claim** — the R6 gap documented on
|
|
/// <see cref="ResolveAndCommitChildAttachment"/> is exactly the
|
|
/// counter-example: a relation CAN be left sitting in
|
|
/// <c>_stagedByChild</c> across calls when the child has a pending
|
|
/// initial-create residence, because <c>TryCommitParent</c>'s gate
|
|
/// isn't satisfied yet. What is true is narrower: THIS retry method
|
|
/// never needs <c>ChildrenWaitingForParent</c>'s STAGED/RECOVERY sweeps
|
|
/// to find that dangling relation, because it re-resolves through
|
|
/// <c>childGuid</c> directly via <c>ResolveAndCommitChildAttachment</c>
|
|
/// on every retry rather than needing a separate discovery query for
|
|
/// already-staged children — only the UNRESOLVED sweep matters for
|
|
/// discovering a NEW parent guid becoming addressable. Scanning the
|
|
/// shared (heavier) <c>ChildrenWaitingForParent</c> on every accepted
|
|
/// headless spawn would pay for two sweeps and a <c>HashSet</c>
|
|
/// allocation this discovery step never needs;
|
|
/// <c>ChildrenUnresolvedForParent</c> scans only
|
|
/// <c>_unresolvedByChild</c>. **Correction (B4, round-3 review): the
|
|
/// first round shared a reused scratch buffer across calls for this
|
|
/// query, which broke reentrancy safety a fresh-array return had —
|
|
/// a reentrant call into this method (or into
|
|
/// <see cref="ResolveAndCommitChildAttachment"/>'s loop below) could
|
|
/// clear/refill the SAME shared list the outer call was still
|
|
/// iterating. Reverted to a fresh return per call, matching
|
|
/// <c>ChildrenWaitingForParent</c>'s own allocation shape**, since this
|
|
/// query already only allocates when it has something to return (most
|
|
/// parent guids have no unresolved children waiting on them). This is
|
|
/// a narrower claim than "0 B" either way — the per-child
|
|
/// <c>queue.Any(lambda)</c> predicate check still allocates a closure
|
|
/// per call, same as the pre-existing graphical sweep; a full
|
|
/// incremental parent-guid->children index would remove both
|
|
/// allocations and is not done in this slice.
|
|
/// </para>
|
|
/// </summary>
|
|
private void RetryChildrenWaitingForParent(uint parentGuid)
|
|
{
|
|
IReadOnlyList<uint> waiting = Entities.Entities.ParentAttachments
|
|
.ChildrenUnresolvedForParent(parentGuid);
|
|
for (int i = 0; i < waiting.Count; i++)
|
|
ResolveAndCommitChildAttachment(waiting[i]);
|
|
}
|
|
|
|
private void OnTeleportStarted(uint rawSequence)
|
|
{
|
|
ushort sequence = unchecked((ushort)rawSequence);
|
|
RuntimeWorldTransitState transit = _runtime.TransitOwner;
|
|
if (!transit.TryQueueTeleportStart(sequence))
|
|
return;
|
|
_worldProjection?.BeginTeleport();
|
|
if (!transit.ActivateQueuedTeleport())
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Runtime rejected its queued headless teleport activation.");
|
|
}
|
|
TryCompletePortal();
|
|
}
|
|
|
|
private void OnAppearanceUpdated(ObjDescEvent.Parsed update) =>
|
|
_ = Entities.TryApplyObjDesc(
|
|
update,
|
|
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;
|
|
if (!transit.TryGetAcceptedTeleportDestination(
|
|
out RuntimeTeleportDestination destination)
|
|
|| !transit.TryBeginPortalReveal(
|
|
destination.TeleportSequence,
|
|
destination.CellId,
|
|
out long generation))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!transit.TryRegisterHostProjection(
|
|
generation,
|
|
destination.CellId,
|
|
out RuntimeWorldHostProjectionToken projection))
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Runtime rejected the headless portal projection.");
|
|
}
|
|
|
|
Acknowledge(
|
|
transit,
|
|
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,
|
|
projection)
|
|
?? new RuntimeDestinationReadiness(
|
|
generation,
|
|
destination.CellId,
|
|
indoor,
|
|
IsUnhydratable: false,
|
|
RequiredRenderRadius: indoor ? 0 : 1,
|
|
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))
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Runtime rejected headless destination readiness.");
|
|
}
|
|
|
|
if (!transit.AcknowledgePortalMaterialized(
|
|
generation,
|
|
destination.TeleportSequence,
|
|
destination.CellId))
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Runtime rejected headless portal materialization.");
|
|
}
|
|
Acknowledge(
|
|
transit,
|
|
projection,
|
|
RuntimeWorldHostAcknowledgementStage
|
|
.SimulationReleaseProjected);
|
|
|
|
if (!transit.RequireDestinationReservationRelease(projection))
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Runtime rejected headless destination release.");
|
|
}
|
|
Acknowledge(
|
|
transit,
|
|
projection,
|
|
RuntimeWorldHostAcknowledgementStage
|
|
.DestinationReservationReleased);
|
|
|
|
if (!transit.AcknowledgeWorldViewportVisible(generation)
|
|
|| !transit.Complete(generation))
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Runtime rejected headless portal completion.");
|
|
}
|
|
Acknowledge(
|
|
transit,
|
|
projection,
|
|
RuntimeWorldHostAcknowledgementStage.TerminalProjected);
|
|
|
|
_session.SendGameAction(GameActionLoginComplete.Build());
|
|
transit.EndTeleport();
|
|
_log(
|
|
$"headless: portal complete generation={generation} "
|
|
+ $"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,
|
|
RuntimeWorldHostAcknowledgementStage stage)
|
|
{
|
|
if (!transit.AcknowledgeHostProjection(
|
|
new RuntimeWorldHostAcknowledgement(
|
|
projection,
|
|
stage)))
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Runtime rejected headless host acknowledgement {stage}.");
|
|
}
|
|
}
|
|
}
|