C5b (735f0a72) made the steady-state accepted-Position merge stop writing residency. That is retail-correct — HandleReceivedPosition @0x00453FD0 reads the wire objcell_id into a local and never assigns the object's cell — and it stays. What C5b did not account for is that its replacement writers both live in AcDream.App: the OnPosition prologue rebucket (AD-60's W2) and the post-routing wire-cell adopt (W3, AP-135). The two hosts run parallel, non-shared inbound routes. LiveEntitySessionController -> LiveEntityNetworkUpdateController.OnPosition is graphical-only; RuntimeLiveEntitySessionController.OnPositionUpdated is the no-window route and is constructed only at HeadlessSessionHost.cs:682. So AcDream.Headless had NO post-merge cell writer at all. Every remote's FullCellId was written at create/placement and then frozen for the session — and RuntimeEntityObjectViews .Snapshot projects exactly that field as RuntimeEntitySnapshot.CellId, i.e. every bot's entire world view. The local player lost one of AP-146's three refresh edges, which matters beyond cosmetics: RuntimeSetPositionState .IsAffectedCollisionResident reads FullCellId to pick which bodies a landblock retirement parks, so a bot running A->B without teleporting would have retired A while parking a body physically in B. The fix, in three parts: 1. RuntimeEntityObjectLifetime.CommitWireCellRebucket — a new Runtime owner for the committed VALUE, extracted verbatim from LiveEntityRuntime .RebucketLiveEntity. This is also the root-cause fix for the layering inversion the review found: AD-60 was documenting its own correctness by naming an App class the Runtime assembly cannot reference. Behaviour on the graphical side is unchanged — record.FullCellId is a proxy for record.Canonical.FullCellId, which is the record the callee reads, and the commit is still CommitRebucket. Verified load-bearing for BOTH hosts: sabotaging the preserve branch reddens the graphical LiveEntityRuntimeTests.CanonicalOnlyRebucket_DoesNotOverwriteAuthoritativeFullCell as well as the new headless assertion. 2. RuntimeLiveEntitySessionController.TryCommitAcceptedWireCell — the no-window W2, under the same reachability rules the graphical route applies: Rejected writes nothing (the shape the App authority gate produces by returning false); a bound-projectile packet writes nothing (routed by the graphical host through the canonical projectile placement owner, which returns before W2); an active initial-create residence writes nothing (RebucketLiveEntity's own early return — while the lease is live the SetPosition conductor is the sole cell authority); a local ForcePosition writes only when the accepted-Position drive declined it (NotApplicable), because a handled force is placement-receipt-authoritative. W2/W3 themselves are untouched. 3. On the committed value (the landblock-vs-cell trap). RebucketLiveEntity's preserve branch fires on a LANDBLOCK-shaped id — low 16 bits 0xFFFF — and exists for LocalPlayerProjectionController.Project, the per-frame local movement caller that emits exactly that shape. An inbound wire objcell_id is never landblock-shaped, so on the accepted-Position route the branch is not taken and the exact wire cell is committed. That is what W2 commits today and what this now commits; the no-window host has no per-frame caller at all. Ordering is matched, not improved on: the force drive submits its placement before the commit, so its first submit still reads the pre-commit FullCellId — AP-138's amended route-2 CurrentCellId measurement. Bookkeeping in this commit: - AD-60 corrected. Its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive; the entire no-window host belonged in it. 23aa62f2's W2/W3-redundancy measurement is preserved verbatim. - AP-146 and #320 amended the same way — their three-edge list was written from the graphical host and silently assumed both hosts shared it. The no-window host had two of three; it now has all three. - AD-64 filed: the reachability decision is now expressed once per host. The value is single-sourced; the gate set is not. - #324 filed: unifying the two session controllers is the genuinely correct fix and is campaign-sized (presentation recovery, hydration, the equipped-child renderer, and the remote/projectile routing arms only one host has). Not attempted here, per the fix brief. Gates. Release build 0 errors. Complete suite 11,141 passed / 4 skipped / 0 failed, against the 11,134 / 4 / 0 baseline at23aa62f2— net +7, exactly the 7 tests added. Eight sabotages verified, each red on at least one discriminating test and green when reverted: remote commit removed (2 Runtime + the end-to-end Headless test); local ordinary commit removed; local NotApplicable-force commit removed; force commit made unconditional; residence gate removed; missile gate removed; Rejected gate removed; preserve branch broken (red on both hosts). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
806 lines
36 KiB
C#
806 lines
36 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
|
|
|| 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 the same
|
|
/// reachability rules the graphical <c>OnPosition</c> route applies to
|
|
/// AD-60's W2. 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 &&
|
|
/// 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>
|
|
/// </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;
|
|
}
|
|
|
|
_ = 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
|
|
/// -> 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;
|
|
}
|
|
|
|
// #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->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}.");
|
|
}
|
|
}
|
|
}
|