acdream/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs
Erik 7b60e71b85 fix(headless,runtime): OP7 review fixes + docs: OP3 re-review REOPEN (narrow)
TWO work products share this commit (a staged-index collision between the
coordinator's docs commit and the OP7 fixer's staged files — content
verified complete and coherent; only this message was wrong before the
amend):

1. OP7 review fixes (all nine findings from
   docs/research/2026-08-11-op7-review.md):
   - M1: HeadlessSessionDescriptor is a record; WithAccount uses 'with' non-destructive record copy,
     so a future property cannot be silently dropped; direct-CLI
     regression test proves CharacterOptions survives --user/--password.
   - M2 root fix: LiveSessionEventRouter skips BOTH Replace and the
     options notification on a trailer-truncated PlayerDescription — a
     truncated re-seed can no longer install zeroed words under an armed
     latch for OP7's automation to flush into 0x01A1.
   - SF1: schema keys validate as ordinal strings against the allowed
     names (numeric / comma-combined aliases rejected). SF2: both-true
     fellowship exclusion rejected at load, naming both keys. SF3: the
     onLoginCompleteSent observer moved after transit.EndTeleport().
     SF4: production-hook coverage for all three LoginComplete sites.
     SF5: test-script OP7 wire expectation corrected (batched ids ride
     only the 0x01A1).

2. docs/research/2026-08-11-op3-rereview.md — OP3 re-review verdict
   REOPEN (narrow): M1 byte-decode independently re-verified (6a 07 at
   all six sites); residuals R1 (gate script promises a timestamp prefix
   acdream doesn't render), R2 (null-controller player-mode still
   refuses), R3 (dormancy pin lacks stimulus) — coordinator fixes follow.

Full Release suite at this tree: 12,956 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 03:22:54 +02:00

914 lines
42 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.Physics;
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;
/// <summary>
/// Campaign OP slice OP7 (2026-08-11): passive observation hook — fires
/// AFTER either of this controller's own two internal
/// <c>GameActionLoginComplete</c> send sites (<see cref="OnSpawned"/>'s
/// content-less immediate-admission path; <see cref="TryAdvancePortalCompletion"/>'s
/// portal-space materialization completion). Never changes when or
/// whether LoginComplete is sent — purely additive, so a headless host
/// can learn "ACE's FirstEnterWorldDone gate is now open" (set-
/// character-options-wire.md §5.2) without duplicating this controller's
/// own dual-path completion logic. The THIRD production send site — direct
/// (non-portal) first-entry completion via
/// <c>RuntimeFirstEntryDriveController</c>'s <c>localPlayerCompleted</c>
/// callback — lives one level up in <c>HeadlessSessionHost</c>, which
/// wires the same observer there directly.
/// </summary>
private readonly Action? _onLoginCompleteSent;
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,
Action? onLoginCompleteSent = null)
{
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_session = session ?? throw new ArgumentNullException(nameof(session));
_log = log ?? (_ => { });
_worldProjection = worldProjection;
_acceptedPositionDrive = acceptedPositionDrive;
_onLoginCompleteSent = onLoginCompleteSent;
_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,
// Effect and sound playback are presentation: the no-window host parses
// these packets and discards them, exactly as it does F754/F755.
_ => { },
_ => { },
_ => { });
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());
_onLoginCompleteSent?.Invoke();
}
}
}
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)
{
// C5b follow-up (2026-08-05), retail finding F2 / architecture
// finding L-A, found independently by both re-reviewers. The
// graphical route validates the wire payload BEFORE the merge —
// LiveEntityNetworkUpdateController.OnPosition computes
// `payloadIsValid` from ProjectileController.CanAcceptPositionPayload
// (retail Position::IsValid @0x005A9480 composed with Frame::IsValid
// @0x00534ED0, plus finite origin/velocity) and
// LiveEntityInboundAuthorityGate.TryAcceptPosition returns false on
// it, ahead of the timestamp gate and every wire-cell writer. Despite
// its name that check is not projectile-scoped; it runs for every
// guid. This route had no equivalent, so an invalid payload merged
// here and then — since D1 — fed its unvalidated LandblockId into
// CommitWireCellRebucket, whose own doc calls a 0 landblock "the
// withdrawal shape": cell 0 + landblock 0, silently de-residencing
// the entity in the exact field every bot reads as
// RuntimeEntitySnapshot.CellId.
//
// The predicate is not re-derived here. It is
// RuntimeAuthoritativePositionRouteClassifier.IsValidCreateWirePosition
// plus the finite-velocity term — literally the pair
// RuntimeEntityObjectLifetime.TryApplyPosition already applies on its
// initial-residence branch, and the same composition the graphical
// gate applies. Rejecting BEFORE the merge (rather than before the
// cell commit alone) is what makes the two hosts genuinely
// symmetric: neither one lets an invalid payload advance the
// timestamp gate.
if (!RuntimeAuthoritativePositionRouteClassifier
.IsValidCreateWirePosition(update.Position)
|| update.Velocity is { } wireVelocity
&& !(float.IsFinite(wireVelocity.X)
&& float.IsFinite(wireVelocity.Y)
&& float.IsFinite(wireVelocity.Z)))
{
return;
}
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
|| disposition is PositionTimestampDisposition.Rejected)
{
// Rejected writes nothing anywhere — the same shape the
// graphical authority gate produces by returning false from
// LiveEntityInboundAuthorityGate.TryAcceptPosition, which is
// ahead of every wire-cell writer.
return;
}
if (!isLocal)
{
// D1 (C5b architecture review): the no-window host's half of
// AD-60's W2. The graphical route commits the accepted wire
// cell for EVERY classification that reaches its generic tail,
// remotes included; this route used to return here, so a
// headless remote's FullCellId was written once at
// create/placement and then frozen for the whole session —
// and RuntimeEntityObjectViews.Snapshot feeds exactly that
// field to every bot's RuntimeEntitySnapshot.CellId.
TryCommitAcceptedWireCell(update);
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.
//
// D1: ... and so does the wire-cell commit. A force the
// drive HANDLED (Committed/DeferredCell) is
// placement-receipt-authoritative for residency, and a
// Rejected/Contention force leaves the last committed
// cell alone (AD-62's shapes) — both are exactly why
// the graphical route returns ahead of W2 on every
// status except NotApplicable. Ordering matters as well
// as reachability: the drive submits its placement
// BEFORE this point, so its first submit reads the
// pre-commit FullCellId, which is the source landblock
// — AP-138's amended route-2 measurement, matched here
// rather than accidentally improved on.
TryCommitAcceptedWireCell(update);
_worldProjection?.ProjectPosition(
record,
isLocalPlayer: true,
disposition);
}
}
else
{
TryCommitAcceptedWireCell(update);
_worldProjection?.ProjectPosition(
record,
isLocalPlayer: true,
disposition);
}
}
TryCompletePortal();
}
/// <summary>
/// D1 (C5b architecture review): commits the accepted wire cell to
/// canonical residency for a no-window host, under reachability rules
/// derived one by one from the graphical <c>OnPosition</c> route's own
/// early returns for AD-60's W2. They are NOT identical, and AD-64
/// enumerates every place they differ — the two absent gates, the
/// residence gate's weaker predicate, and the missile gate's structural
/// drift risk. The committed VALUE is
/// <see cref="RuntimeEntityObjectLifetime.CommitWireCellRebucket"/>'s —
/// one rule, shared by both hosts, including its landblock-vs-cell
/// branch.
///
/// <para>
/// Two gates mirror callers the graphical route has and this one does
/// not. <b>The initial-create residence</b> is
/// <c>LiveEntityRuntime.RebucketLiveEntity</c>'s own early return
/// (<c>MaterializationResidence is AwaitRuntimePlacement &amp;&amp;
/// HasActiveInitialCreateResidence</c>): while the lease is live,
/// Runtime's <c>SetPosition</c> conductor is the sole cell authority.
/// Only the residence half is tested here, because it IS the whole
/// test on this side — the App enum's <c>AwaitRuntimePlacement</c> value
/// exists to mark records that took the residence route, which is every
/// record a projection-backed direct host registers, and a content-less
/// direct host opens no lease at all
/// (<see cref="OnSpawned"/>). <b>A missile packet</b> is routed by the
/// graphical host through the canonical projectile placement owner and
/// returns before W2; the predicate below is the exact conjunction that
/// route's own null-classification arm uses
/// (<c>LiveEntityNetworkUpdateController.OnPosition</c>, the
/// <c>isMissilePacket</c> ternary), which its D-P1 comment records as
/// equivalent to the classifier's <c>ProjectileAuthoritative</c>
/// operation kind. Committing a wire cell for a projectile here would
/// invent residency a placement route owns.
/// </para>
///
/// <para>
/// <b>Two known imprecisions, both host-symmetric and both pre-existing;
/// filed at AD-64 rather than papered over here.</b> (1) The residence
/// gate uses <c>TryGetInitialCreateResidence</c> (<c>TryGetCurrent</c>),
/// while <c>RuntimeEntityObjectLifetime.TryApplyPosition</c>'s own FIFO
/// branch uses the strictly WEAKER <c>TryGetPendingInitialResidence</c>
/// (<c>TryGetTransaction</c> = current OR a completed-but-unretired
/// lease). In that window the merge enqueues the packet as a
/// continuation while this gate reads "no residence" and commits the
/// wire cell ahead of the continuation that will replay it. The
/// graphical route's <c>RebucketLiveEntity</c> reads the same weaker
/// predicate, so both hosts have it identically. (2) The missile
/// predicate below is the graphical route's FALLBACK conjunction; that
/// route PREFERS <c>earlyRemoteRoute.OperationKind is
/// ProjectileAuthoritative</c> and drops to the conjunction only when
/// the classification is null. The two agree today — the conjunction is
/// what the classifier's own projectile test is built from — but they
/// are separate expressions and only one of them is reachable here,
/// because this route classifies nothing for a remote.
/// </para>
/// </summary>
private void TryCommitAcceptedWireCell(
WorldSession.EntityPositionUpdate update)
{
if (!Entities.Entities.TryGetActive(
update.Guid,
out RuntimeEntityRecord canonical)
|| Entities.TryGetInitialCreateResidence(canonical, out _)
|| IsMissilePacket(canonical, update.Guid))
{
return;
}
// The bool is discarded, where the graphical caller
// (LiveEntityRuntime.RebucketLiveEntity) treats false as
// ThrowAfterCommittedProjectionChange. That is not a suppressed
// failure: false means `Entities.IsCurrent(canonical)` went stale, and
// TryGetActive above returned the CURRENT record synchronously three
// statements earlier on the same thread, with nothing in between that
// can retire it. The graphical caller needs the test because it has
// already published spatial/presentation changes by that point and a
// stale canonical would leave them orphaned; this route publishes
// nothing ahead of the commit, so there is no half-applied state to
// detect. Asserting on it would be asserting on an unreachable value.
_ = Entities.CommitWireCellRebucket(
canonical,
update.Position.LandblockId);
}
private bool IsMissilePacket(
RuntimeEntityRecord canonical,
uint guid) =>
guid != _runtime.PlayerIdentity.ServerGuid
&& (canonical.FinalPhysicsState & PhysicsStateFlags.Missile) != 0
&& canonical.Projectile is { } projectile
&& ReferenceEquals(canonical.PhysicsBody, projectile.Body);
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
/// -&gt; committed protocol the graphical
/// <c>EquippedChildRenderController.ResolveAndTryRealize</c> /
/// <c>PrepareAndTryRealize</c> pair runs —
/// <see cref="ParentAttachmentState.Resolve"/>, then
/// <see cref="RuntimeEntityObjectLifetime.TryCommitParent"/> -&gt;
/// <see cref="ParentAttachmentState.CommitProjection"/> -&gt;
/// <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;
}
// #319 A1 (architecture review, 2026-08-05): same ordering fix as
// the graphical host's PrepareAndTryRealize - the incarnation
// tripwire must run before TryCommitParent's canonical mutation,
// never after, so a mismatch refuses cleanly instead of tearing the
// transaction.
if (!relations.CanCommitIncarnation(staged, _resolveParentInstance))
{
relations.RejectProjection(staged);
return false;
}
ulong positionAuthorityVersion = canonical.PositionAuthorityVersion;
if (!Entities.TryCommitParent(staged, acknowledgeProjection: null, out _)
|| !relations.CommitProjection(staged, _resolveParentInstance))
{
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-&gt;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,
// #280: this "centre ring" token is NOT the graphical host's
// derived reveal radius and must not be made to track it. This
// host has no streaming window, no render publication and no
// composites, so there is nothing wider for a radius to mean
// here. Runtime validates only the SHAPE (indoor => 0,
// outdoor => >= 1), which this satisfies by construction.
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}");
// SF-3 (Campaign OP OP7 review fix, 2026-08-11): invoke AFTER the
// teleport-completion tail, matching the other production call
// site's shape (OnSpawned, above). The observer body is not
// trivial — it runs the full diff, real SendGameActions, and
// event-hub publication to bot policies — so a throw from it must
// not abort transit.EndTeleport() with the retry token already
// discarded.
_onLoginCompleteSent?.Invoke();
}
/// <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}.");
}
}
}