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()