From 78b981cca0031dc46b8a99482fcc9a0bbb6b2ffa Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 9 Aug 2026 12:32:53 +0200 Subject: [PATCH] fix(runtime): transient collision-seal failure no longer terminal for login (#357) Login could hang forever at reveal ready=True with the world never revealed: UI and sky drawn, geometry absent, client healthy. The player's first-entry conductor was being terminally dropped by a TRANSIENT condition. Mechanism, pinned by probes: the C3c-F2 rearm guard validates the exact destination cell's prefix admissibility before moving the dormant lease out of AwaitingCell, but the placement transaction's ring search touches NEIGHBOUR landblocks and TrySealCollisionEvaluationAuthority covers every touched prefix. A hard login recenter admits nine landblocks at once, so a rearm taken while a neighbour's admission was still registered passed the guard and failed the seal. The operation was left in AwaitingPreparation, IsDormantLocalActivationAwaitingCell went false, and EvaluateActivation had no way to say 'retry' - it fell through to RejectedAuthority, which RuntimeFirstEntryDriveController treats as terminal. The local player left the pump (pending=0), the movement controller never published, auto-entry never fired, the reveal never completed. Timing-flipped: the same binary worked when the rearm landed outside a neighbour's admission window, then lost that race consistently. Fix is classification, not state: EvaluateActivation reports DeferredCell when the abort happens while the dormant lease is still current (IsDormantLocalActivationLeaseCurrent), so the conductor keeps retrying. The operation deliberately stays in AwaitingPreparation - the retry re-runs the full evaluation against fresh state, which is the recovery contract the publication-state tests already pin (the SAME token evaluates Evaluated once the authority settles). Genuine discards still report RejectedAuthority. A first attempt that re-parked the lease to AwaitingCell was rejected by the test matrix: recovery would then need the rearm gate, which is stricter than the seal, and the reentrant-restriction-mutation recoveries hung in DeferredCell. Seven publication-state tests move their transient-abort assertion from RejectedAuthority to DeferredCell; the two genuinely-terminal tests (lease retired) are unchanged. The [wake]/[rearm]/[pump] probes that pinned the mechanism stay behind ACDREAM_PROBE_PARK=1 with the rest of the C4 family. Exonerated by experiment before the fix: ACE (wire capture shows PlayerCreate sent; retail logs in fine) and the portal-cue commit 2914e43a (full revert stalled identically). Verified: 2/2 live logins reach auto-entered player mode and reveal event=complete, with the probe showing seal-refused -> retry -> recovery in flight; full Release suite 11,740 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Opus 5 --- docs/ISSUES.md | 42 ++++++++- ...ntimeLocalPlayerPhysicsPublicationState.cs | 22 +++++ .../Physics/RuntimeSetPositionState.cs | 85 +++++++++++++++++-- .../RuntimeFirstEntryDriveController.cs | 8 ++ ...LocalPlayerPhysicsPublicationStateTests.cs | 32 ++++++- 5 files changed, 177 insertions(+), 12 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 92981314..c5ff3ea1 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -39,7 +39,47 @@ published-controller behaviour is unchanged. ## #357 — Login stalls: reveal reaches ready=True but the player is never placed; UI + sky render, world never opens -**Status:** OPEN — filed 2026-08-08 during the Campaign A listening session, +**Status:** CLOSED 2026-08-08 — root-caused and fixed same session (see the +commit referencing this issue). **Root cause:** a transient collision- +authority seal failure was classified as TERMINAL for the login conductor. +The C3c-F2 rearm guard validates the EXACT destination cell's prefix before +leaving `AwaitingCell`, but the placement transaction's ring search touches +NEIGHBOUR landblocks, and `TrySealCollisionEvaluationAuthority` covers every +touched prefix — so a rearm taken while a neighbour's collision admission +was still registered (a hard login recenter admits nine at once) passed the +guard and then failed the seal. The operation was left in +`AwaitingPreparation`, which made `IsDormantLocalActivationAwaitingCell` +false, which forced `EvaluateActivation` to report `RejectedAuthority` — +terminal for `RuntimeFirstEntryDriveController`, which dropped the local +player from its pump (`pending=0`), so the movement controller never +published, auto-entry never fired, and the reveal never completed. +Probe signature: `[rearm] verdict=OK` once, then silence. + +**Fix (classification, not state):** `EvaluateActivation` now reports +`DeferredCell` when the evaluation aborts while the dormant lease is still +current (`IsDormantLocalActivationLeaseCurrent`), keeping the conductor +retrying; the operation deliberately stays in `AwaitingPreparation` so the +retry re-runs the full evaluation against fresh state — the recovery path +the publication-state tests already pin (the same token evaluates +`Evaluated` once the authority settles). Genuine discards (lease retired / +not current) still report `RejectedAuthority`. A first attempt that +re-parked the operation back to `AwaitingCell` was REJECTED by the test +matrix: recovery would then require the rearm gate, which is stricter than +the seal, and the reentrant-restriction-mutation tests hung in +`DeferredCell`. + +Seven publication-state tests updated from `RejectedAuthority` to +`DeferredCell` at their transient-abort assertions (their substance — +abort now, retained pending activation, same-token recovery — was already +the retryable contract; only the status name told the conductor to give +up). The `[wake]`/`[rearm]`/`[pump]` probes that pinned the mechanism are +kept behind `ACDREAM_PROBE_PARK=1` with the rest of the C4 family. + +The portal-cue commit (`2914e43a`) was suspected and EXONERATED by +experiment: a build with it fully reverted stalled with the identical +probe signature. Wire capture had already exonerated ACE. + +**Original filing (evidence chain preserved below):** — filed 2026-08-08 during the Campaign A listening session, which it blocks. **This is a placement/streaming bug, not an audio bug** — read `docs/research/2026-08-05-c4-closeout-handoff.md` and `claude-memory/project_physics_collision_digest.md` before touching it. diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs index 27d894f5..19f30dfb 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs @@ -510,6 +510,28 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable { return RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell; } + // #357: an evaluation abort while the dormant lease is STILL + // CURRENT is transient — the collision-authority seal was refused + // by a still-registered admission (a login recenter admits nine + // landblocks at once and the placement's ring search can touch a + // neighbour mid-admission) or a reentrant collision/restriction + // mutation observed mid-transaction. The operation remains in + // AwaitingPreparation and the SAME token evaluates successfully + // once the authority settles, so the correct status is + // DeferredCell (retry). Falling through to RejectedAuthority here + // is what terminally dropped the local player from the + // first-entry conductor and hung login at ready=True with the + // world never revealed. + if (ReferenceEquals(_activation, activation) + && IsActivationCurrent(activation) + && _physics.SetPosition.IsDormantLocalActivationLeaseCurrent( + activation.Record, + activation.Body, + token.Placement, + activation.PlacementCommand)) + { + return RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell; + } if (ReferenceEquals(_activation, activation) && !IsActivationCurrent(activation)) { diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs index 9e0d9fb0..fe3ae34d 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs @@ -2067,6 +2067,24 @@ internal sealed class RuntimeSetPositionState : IDisposable objectTableAuthority, out RuntimeCollisionEvaluationAuthority collisionAuthority)) { + // #357: a failed seal is a TRANSIENT abort — an authority moved + // between the evaluation snapshot and the seal (a still-registered + // neighbour admission during a login recenter, a reentrant + // collision/restriction mutation observed mid-transaction). The + // operation is deliberately left in AwaitingPreparation: the next + // EvaluateActivation call re-runs the full placement against fresh + // state, which is the recovery path the publication-state tests + // pin. The retryable-vs-terminal CLASSIFICATION happens in + // RuntimeLocalPlayerPhysicsPublicationState.EvaluateActivation, + // which reports DeferredCell while this lease is still current — + // before #357 it could only report RejectedAuthority here, which + // the first-entry conductor treats as terminal, dropping the + // local player and hanging login at ready=True forever. + if (Core.Physics.PhysicsDiagnostics.ProbeParkEnabled) + { + Console.WriteLine(FormattableString.Invariant( + $"[rearm] guid=0x{record.ServerGuid:X8} seal-refused (transient; lease retained)")); + } return false; } @@ -2170,13 +2188,36 @@ internal sealed class RuntimeSetPositionState : IDisposable in RuntimeEntityPlacementToken token, in RuntimeSetPositionCommand command) { - if (!IsExactDormantLocalActivationCurrent( - record, - body, - token, - command, - out Operation? operation, - allowDeferredLease: true) + bool current = IsExactDormantLocalActivationCurrent( + record, + body, + token, + command, + out Operation? operation, + allowDeferredLease: true); + if (Core.Physics.PhysicsDiagnostics.ProbeParkEnabled) + { + string why = !current || operation is null + ? "not-current" + : operation.Stage is not RuntimeEntityPlacementStage.AwaitingCell + ? $"stage={operation.Stage}" + : !operation.DormantLocalActivation ? "not-dormant" + : !operation.WakeableLostCell ? "not-wakeable" + : !operation.CollisionGenerationReady ? "gen-not-ready" + : operation.ProjectionSequence != 0UL ? "proj-seq" + : operation.CollisionGeneration != _physics + .CollisionGenerationAuthority(operation.ExactCellId) + ? $"gen-mismatch({operation.CollisionGeneration}!={_physics.CollisionGenerationAuthority(operation.ExactCellId)})" + : !_physics.Engine.IsSpawnCellReady(operation.ExactCellId) + ? "spawn-not-ready" + : !_physics.IsCollisionEvaluationPrefixAdmissible( + operation.ExactCellId) + ? "prefix-inadmissible" + : "OK"; + Console.WriteLine(FormattableString.Invariant( + $"[rearm] guid=0x{record.ServerGuid:X8} verdict={why}")); + } + if (!current || operation is null || operation.Stage is not RuntimeEntityPlacementStage.AwaitingCell || !operation.DormantLocalActivation @@ -4281,6 +4322,11 @@ internal sealed class RuntimeSetPositionState : IDisposable EnsureNotDisposed(); if (generation == 0UL) throw new ArgumentOutOfRangeException(nameof(generation)); + if (Core.Physics.PhysicsDiagnostics.ProbeParkEnabled) + { + Console.WriteLine(FormattableString.Invariant( + $"[wake] begin lb=0x{landblockId:X8} gen={generation} unboundCells={_unboundDeferredCellOrder.Count} buckets={_deferredBucketOrder.Count}")); + } uint prefix = landblockId & 0xFFFF0000u; for (int index = 0; index < _unboundDeferredCellOrder.Count;) { @@ -4352,6 +4398,11 @@ internal sealed class RuntimeSetPositionState : IDisposable internal void CancelCollisionGeneration(uint landblockId, ulong generation) { EnsureNotDisposed(); + if (Core.Physics.PhysicsDiagnostics.ProbeParkEnabled) + { + Console.WriteLine(FormattableString.Invariant( + $"[wake] cancel lb=0x{landblockId:X8} gen={generation} buckets={_deferredBucketOrder.Count}")); + } if (_deferredBucketOrder.Count == 0) return; uint prefix = landblockId & 0xFFFF0000u; @@ -4371,6 +4422,11 @@ internal sealed class RuntimeSetPositionState : IDisposable bool ready) { EnsureNotDisposed(); + if (Core.Physics.PhysicsDiagnostics.ProbeParkEnabled) + { + Console.WriteLine(FormattableString.Invariant( + $"[wake] commit lb=0x{landblockId:X8} gen={generation} ready={ready} buckets={_deferredBucketOrder.Count}")); + } if (!ready || _deferredBucketOrder.Count == 0) return; @@ -4396,9 +4452,19 @@ internal sealed class RuntimeSetPositionState : IDisposable RuntimeEntityKey[] exact = indexed.ToArray(); if (!_physics.Engine.IsSpawnCellReady(cell.CellId)) { + if (Core.Physics.PhysicsDiagnostics.ProbeParkEnabled) + { + Console.WriteLine(FormattableString.Invariant( + $"[wake] STRAND cell=0x{cell.CellId:X8} gen={cell.CollisionGeneration} spawnReady=false ops={exact.Length} -> unbound")); + } UnbindDeferredBucket(cell); continue; } + if (Core.Physics.PhysicsDiagnostics.ProbeParkEnabled) + { + Console.WriteLine(FormattableString.Invariant( + $"[wake] wake cell=0x{cell.CellId:X8} gen={cell.CollisionGeneration} ops={exact.Length}")); + } RemoveDeferredBucket(cell); // Round 3 audit: safe - `exact` snapshots KEYS (RuntimeEntityKey // values), never Operation references, so nothing here can go @@ -4425,6 +4491,11 @@ internal sealed class RuntimeSetPositionState : IDisposable continue; } operation.CollisionGenerationReady = true; + if (Core.Physics.PhysicsDiagnostics.ProbeParkEnabled) + { + Console.WriteLine(FormattableString.Invariant( + $"[wake] op guid=0x{operation.Record.ServerGuid:X8} dormant={operation.DormantLocalActivation} ack={operation.WithdrawalAcknowledged} stage={operation.Stage} reqPrep={operation.RequiresPreparation} projSeq={operation.ProjectionSequence}")); + } if (operation.WithdrawalAcknowledged) RetryDeferred(operation); } diff --git a/src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs b/src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs index 8dd3aff9..544b81d6 100644 --- a/src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs +++ b/src/AcDream.Runtime/Session/RuntimeFirstEntryDriveController.cs @@ -125,8 +125,16 @@ internal sealed class RuntimeFirstEntryDriveController /// synchronous callbacks reaching a host pump) fail closed into the next /// outer pump instead of interleaving. /// + private long _driveAllCalls; + internal void DriveAll() { + if (Core.Physics.PhysicsDiagnostics.ProbeParkEnabled + && (++_driveAllCalls <= 5 || _driveAllCalls % 300 == 0)) + { + Console.WriteLine(FormattableString.Invariant( + $"[pump] DriveAll #{_driveAllCalls} pending={_pending.Count}")); + } if (_driving || _pending.Count == 0) return; _driving = true; diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs index b64037d4..bbb579f2 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs @@ -1413,7 +1413,13 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests Assert.False(body.InWorld); Assert.False(body.TransientState.HasFlag(TransientStateFlags.Active)); Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record)); - Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.RejectedAuthority, + // #357: a seal refused by a still-registered admission or a + // reentrant collision/restriction mutation is a TRANSIENT abort - + // the activation stays pending and the SAME token evaluates + // successfully afterwards (both asserted below). The status is + // DeferredCell so the first-entry conductor keeps retrying instead + // of terminally dropping the local player, which hung login. + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell, fixture.Owner.EvaluateActivation(token, out _)); Assert.Equal(1, fixture.Owner.CaptureOwnership() .PendingActivationCount); @@ -1459,7 +1465,13 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests return observed; }; - Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.RejectedAuthority, + // #357: a seal refused by a still-registered admission or a + // reentrant collision/restriction mutation is a TRANSIENT abort - + // the activation stays pending and the SAME token evaluates + // successfully afterwards (both asserted below). The status is + // DeferredCell so the first-entry conductor keeps retrying instead + // of terminally dropping the local player, which hung login. + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell, fixture.Owner.EvaluateActivation(token, out _)); Assert.True(mutated); Assert.Equal(1, fixture.Owner.CaptureOwnership() @@ -1504,7 +1516,13 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests return observed; }; - Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.RejectedAuthority, + // #357: a seal refused by a still-registered admission or a + // reentrant collision/restriction mutation is a TRANSIENT abort - + // the activation stays pending and the SAME token evaluates + // successfully afterwards (both asserted below). The status is + // DeferredCell so the first-entry conductor keeps retrying instead + // of terminally dropping the local player, which hung login. + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell, fixture.Owner.EvaluateActivation(token, out _)); Assert.True(committed); Assert.Equal(1, fixture.Owner.CaptureOwnership() @@ -1736,7 +1754,13 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests return observed; }; - Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.RejectedAuthority, + // #357: a seal refused by a still-registered admission or a + // reentrant collision/restriction mutation is a TRANSIENT abort - + // the activation stays pending and the SAME token evaluates + // successfully afterwards (both asserted below). The status is + // DeferredCell so the first-entry conductor keeps retrying instead + // of terminally dropping the local player, which hung login. + Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell, fixture.Owner.EvaluateActivation(token, out _)); Assert.True(mutated); Assert.Equal(1, fixture.Owner.CaptureOwnership()