diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 202e501b..0e07ecbb 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -123,18 +123,81 @@ CH4 REJECT-review). ## #365 — Headless host cannot move at head: session quarantines on the first advance tick; world never hydrates -**Status:** OPEN — filed 2026-08-10 during the Campaign CH jump-probe -reproduction. Distinct from #330 (no collision) and #332 (no remote DR): -this is the LOCAL player. +**Status:** CLOSED 2026-08-10. Root-caused and fixed per +`docs/research/2026-08-10-365-headless-hydration-diagnosis.md` (see its +OUTCOME section for the measured verdict and fix shape). Distinct from #330 +(no collision) and #332 (no remote DR): this was the LOCAL player. -**Symptom:** a headless session (`jump-probe` policy, real ACE connect, -`testaccount`/`+Acdream`) reaches `InWorld`, `live: in world — CreateObject -stream active` prints, but `entityCount` stays **0** for the whole run and -the local movement controller is never published. The first -`SetIntent(Jump: true)` → next `AdvanceBeforeNetwork` tick quarantined the -session. +**Correction to the evidence chain below:** `entities: 0` in the headless +JSON is NOT evidence of failed hydration. `HeadlessDiagnosticWriter.Lifecycle` +fires at exactly four points (constructed / start-result / reconnect-deferred +/ stopped), none of which run after a CreateObject stream has had any chance +to populate the entity table — a perfectly healthy run prints the SAME +`entities: 0` on every lifecycle line. `entityCount` was a logging artifact, +not a hydration symptom. -**Evidence chain (2026-08-10):** +**Actual root cause (confirmed via live capture with `ACDREAM_PROBE_PARK=1`, +unblocked by the Step-1 audit fix below):** the headless host's ONLY +collision publisher is a 3×3 landblock plan STARTED BY the local player's own +CreateObject (`HeadlessCollisionNeighborhood`) — unlike the graphical host's +publisher, which runs on the streaming cadence ahead of the Create burst. +`HeadlessSessionWorldProjection.PumpFirstEntry`/`ProjectSpawn`/`ProjectPosition` +drove the first-entry conductor UNCONDITIONALLY, including while the +neighborhood's own publication held a genuinely open +`RuntimeCollisionAdmission` for the same landblock the player's placement +needed. Every `TrySealCollisionEvaluationAuthority` attempt during that +window failed (`IsCollisionEvaluationPrefixAdmissible` false) and retried +every tick without ever recovering while the window stayed open. Measured +verdict: `seal-refused` repeating with NO preceding `[rearm] verdict=` line — +the operation never even reached the `AwaitingCell` park; it failed the seal +immediately on every attempt while `AwaitingPreparation`. This is the +diagnosis doc's "structural half — CONFIRMED" mechanism, not its "circular +`HasOldPrefixPlacementDebt`" hypothesis (no `prefix-inadmissible` rearm +verdicts were ever observed). + +**Fix (Step 3a):** new `IHeadlessCollisionNeighborhood.IsQuiescent` +(`_pendingPublication is null && _publicationQueue.Count == 0 && +!_pendingPublicationCancellation`) gates all three drive call sites +(`ProjectSpawn`, `ProjectPosition`, `PumpFirstEntry`) — the conductor is +never driven while the neighborhood's own publication owns collision +authority for that tick. + +**Fix (Step 4, defense-in-depth):** `HeadlessLocalPlayerFrameHost.CanAdvancePlayer` +now requires `Controller is { CanExecuteLiveMovement: true }` instead of just +`Controller is not null` — the exact bug that turned the (now-fixed) +hydration stall into a hard crash (a dormant, unpublished controller reaching +`SuspendObjectUpdate`). `RuntimeLocalPlayerFrameController`'s three shared +entry points (`AdvanceBeforeNetwork`/`RunPostNetworkCommandPhase`/ +`TryGetPresentationAfterNetwork`) gained the same +`controller.CanExecuteLiveMovement` guard, contract-preserving for the +graphical host. + +**Enabler (Step 1):** `HeadlessStaticStateAudit.ValidateProcessIsolation` +now takes `sessionCount` and only refuses process-global physics probes for +`sessionCount > 1` (logging, not refusing, for the single-session case) — the +audit's own rationale (multi-root attribution ambiguity) never applied to a +single session, and it was blocking the exact probe (`ACDREAM_PROBE_PARK=1`) +built to diagnose this class of stall. + +**End-to-end verification:** confirmed via three live `jump-probe` runs +against local ACE — hydration now succeeds cleanly (136 entities load, +"local player present" fires promptly, `[jump-probe] releasing jump (fire)` +reached — no `seal-refused` spam, no crash from the original bug) and the +session exits gracefully every time (`[session] graceful logout confirmed`, +zero leases at disposal). Full `airborne-transition True` confirmation is +blocked by a SEPARATE, newly-discovered, pre-existing defect — see #368 below +— not by anything in this issue's scope. A diagnostic-only run with #368's +guard temporarily neutralized (never shipped, reverted before commit) +confirmed the #365 fix produces the correct behavior once past that unrelated +blocker: full hydration, the jump-probe policy running to completion, exit +code 0. + +**Also observed in the same runs:** `[weenie-error] unmapped code=0x051D` — +an ACE-only id outside retail's 344-case `HandleFailureEvent` switch; CH2's +silent-toward-player + diagnostics-line fallback handled it as designed (no +action needed, noted for completeness). + +**Original evidence chain (2026-08-10, superseded by the root cause above):** 1. First quarantine: `RuntimeLocalPlayerFrameController.AdvanceBeforeNetwork:93` unconditionally re-assigned `controller.LocalEntityId` — a sealed configuration property — on a still-dormant controller. FIXED in the @@ -144,16 +207,8 @@ session. `ObjectClockDisposition == Suspend` branch and calls `SuspendObjectUpdate` → `EnsurePublishedForRuntimeOperation` throws — `_host.CanAdvancePlayer` is true while the controller is unpublished. -3. Root condition: the session's world never hydrates (`entities: 0` - despite the active CreateObject stream), so dormant local activation - never completes and the controller never publishes. K3/K4 ran full - connected multi-session gates with movement — this regressed somewhere - in the many Runtime/placement/CH landings since 2026-07-27. - -**Also observed in the same run:** `[weenie-error] unmapped code=0x051D` — -an ACE-only id outside retail's 344-case `HandleFailureEvent` switch; -CH2's silent-toward-player + diagnostics-line fallback handled it as -designed (no action needed, noted for completeness). +3. Root condition (at the time): believed to be "the session's world never + hydrates" from the `entities: 0` artifact — corrected above. **Repro:** `dotnet run --project src/AcDream.Headless -c Release -- run --config ` with a `jump-probe` policy session against local ACE @@ -161,10 +216,73 @@ designed (no action needed, noted for completeness). Environment). `HeadlessDiagnosticWriter.Failure` now emits `errorDetail` (full exception) — added during this diagnosis. -**Next step:** bisect the headless hydration path (why zero entities admit -headless when the graphical host hydrates fine) BEFORE touching -`CanAdvancePlayer` — the #357 closeout warns that dormant-activation -classification changes have a strict test matrix. +## #368 — Headless scheduler's async tick loop can run collision-generation calls on different threads, tripping `EnsureCollisionMutationThread` + +**Status:** OPEN — filed 2026-08-10 during #365's end-to-end verification. +Explicitly OUT OF SCOPE for #365 — orthogonal mechanism, not mentioned +anywhere in that diagnosis. + +**Symptom:** a real headless run against live ACE (`jump-probe` policy, +`ACDREAM_PROBE_PARK=1`) that survives long enough for the local player's own +landblock collision generation to span more than a couple of scheduler ticks +reliably quarantines with: + +``` +System.InvalidOperationException: Collision generations must be staged and +committed on one update thread. + at AcDream.Runtime.Physics.RuntimePhysicsState.EnsureCollisionMutationThread() + at AcDream.Runtime.Physics.RuntimePhysicsState.AdvanceCollisionGenerationSeal(...) + at AcDream.Headless.Hosting.HeadlessCollisionGenerationTransaction.Advance() + at AcDream.Headless.Hosting.HeadlessCollisionNeighborhood.AdvanceWork() + at AcDream.Headless.Hosting.HeadlessCollisionNeighborhood.IsReady(...) + at AcDream.Headless.Hosting.HeadlessSessionWorldProjection.PumpFirstEntry() + at AcDream.Headless.Hosting.HeadlessSessionHost.Tick(...) + at AcDream.Headless.Hosting.HeadlessProcessScheduler.DispatchSessionDue(...) +``` + +Reproduced identically across 3 separate live-ACE runs (2026-08-10), each +time at the same point (`[wake] begin lb=0x0904FFFF gen=1` — right after the +jump-probe policy's "local player present" line) — not a one-off timing +fluke. + +**Root cause (confirmed by reading `RuntimePhysicsState.EnsureCollisionMutationThread` ++ `ResetSessionPhysics`'s own doc comment):** the guard binds the FIRST +thread that calls any collision-mutating method for a generation +(`_collisionMutationThreadId`, `Interlocked.CompareExchange`) and requires +every later call on that generation to match — a real invariant for the +graphical host, whose whole session runs on one dedicated update thread. +`HeadlessProcessScheduler.RunAsync` instead drives its ticks through +`await Task.Delay(delay, _timeProvider, cancellationToken).ConfigureAwait(false)` +— a console app has no `SynchronizationContext`, so each resumption after the +delay can legitimately land on a different ThreadPool worker. The FIRST +collision-mutating call (during the session's opening synchronous tick, +still on the process's original thread) binds the guard to that thread; any +LATER tick that resumes on a different pooled thread and also calls into +collision generation trips it. + +**Why K1–K4's connected gates never caught it:** those gates' own collision +generations apparently completed within tick sequences that stayed on the +same pooled thread (low contention on those runs), or the exact interleaving +needed to cross a real `Task.Delay` resumption boundary mid-generation never +occurred. Every fixture test in this repo drives `Tick()` synchronously and +directly, never through the real `HeadlessProcessScheduler.RunAsync` await +loop — so none of them exercise this path either. A genuine coverage gap, +not a regression from a specific commit. + +**NOT fixed here.** A proper fix needs its own investigation (e.g. a +dedicated single update thread for the scheduler's tick loop, or redesigning +the thread-affinity invariant for verified multi-thread-safe callers) and +must be verified against the GRAPHICAL host too — out of reach for the +session that found this (constrained not to launch the graphical client). +Do not "fix" this by loosening or removing `EnsureCollisionMutationThread`'s +check — it guards a real invariant elsewhere in the physics/collision +system, and a workaround shape here is exactly what CLAUDE.md's +no-workarounds rule forbids without explicit approval. + +**Repro:** run the `jump-probe` policy against local ACE with +`ACDREAM_PROBE_PARK=1` for long enough that the local player's own landblock +collision generation spans more than a couple of scheduler ticks (the +default case against a real DAT-loaded landblock). ## #366 — Chat window's new-unseen-text indicator (0x1000048C) imports but is never independently wired diff --git a/docs/research/2026-08-10-365-headless-hydration-diagnosis.md b/docs/research/2026-08-10-365-headless-hydration-diagnosis.md index 9cf6c099..44d7a698 100644 --- a/docs/research/2026-08-10-365-headless-hydration-diagnosis.md +++ b/docs/research/2026-08-10-365-headless-hydration-diagnosis.md @@ -353,3 +353,97 @@ that +Acdream walks — the K3/K4 gate unrun since 2026-08-02. | 529e0e9d root cause; 78b981cc changed the symptom | High | git show 529e0e9d -- src/AcDream.Headless/ | | Probes unrunnable headless | Certain | HeadlessProcessHost.cs:45 | | Existing hydration test bypasses admission/seal | Certain | HeadlessSessionHostTests.cs:372-375 | + +## 8. OUTCOME (2026-08-10, fix session) + +Steps 1, 3a, and 4 landed exactly as specified; **3b was not needed.** + +**Step 2 measurement (mandatory, run before any Step-3 code):** the +`ACDREAM_PROBE_PARK=1` jump-probe repro against local ACE produced + +``` +[pump] DriveAll #1 pending=0 +... +[wake] begin lb=0x0904FFFF gen=1 unboundCells=0 buckets=0 +[rearm] guid=0x5000000A seal-refused (transient; lease retained) +[rearm] guid=0x5000000A seal-refused (transient; lease retained) +[jump-probe] local player present; charging jump +``` + +— `seal-refused` repeating with **no preceding `[rearm] verdict=` line**. +Per this doc's own Step-2 table, that shape means the operation never even +reached the `AwaitingCell` park: `IsExactDormantLocalActivationCurrent` +already reports "current" on every attempt (the op sits in +`AwaitingPreparation` the whole time), so `TryRearmDeferredDormantLocalActivation` +is never called, and every attempt fails at +`TrySealCollisionEvaluationAuthority` on the SAME still-open admission. This +is the doc's §3 "structural half — CONFIRMED" mechanism. **No +`prefix-inadmissible` rearm verdict was ever observed**, so there is no +direct evidence the §3 "why it may never open" `HasOldPrefixPlacementDebt` +circular-wait hypothesis is in play for this repro — 3a alone was measured +sufficient. + +**Fix shape: 3a only.** `IHeadlessCollisionNeighborhood.IsQuiescent` +(`_pendingPublication is null && _publicationQueue.Count == 0 && +!_pendingPublicationCancellation`) gates `ProjectSpawn`/`ProjectPosition`/ +`PumpFirstEntry`'s trailing `_firstEntry?.DriveAll()` / `_acceptedPositionDrive?.Advance()` +calls exactly as specified. Step 4 landed as specified +(`CanAdvancePlayer` requires `CanExecuteLiveMovement`, plus the three shared +`RuntimeLocalPlayerFrameController` entry points hardened the same way). +Step 1 landed as specified (`ValidateProcessIsolation(sessionCount)`, single +session + probe logs and proceeds, multi-session + probe still refuses, +naming the probe). + +**Verification that the new test (item 3) actually discriminates:** +temporarily reverting the three `IsQuiescent` gates (commented out, never +committed) made both +`RealAdmissionNeverDrivesTheConductorWhileOpenAndHydratesOnceReleased` and +`PumpFirstEntryWithholdsDriveAllUntilQuiescentThenDrivesImmediately` fail — +`Assert.Null(runtime.MovementOwner.Controller)` failed because the controller +was ALREADY built and published (`CanExecuteLiveMovement = True`) while the +neighborhood's admission was still held open, exactly the pre-fix race. Both +pass cleanly on the real, fixed tree. The gate was then restored and +`git diff` confirmed the file matches the shipped Step-3a diff exactly (no +residual simulation code). + +**A design note for the test:** the first attempt at test item 3 opened the +held admission on the PLAYER'S OWN landblock via the full +`HeadlessCollisionGenerationTransaction` commit cycle. That hit a genuine, +separate settlement question in `CommitCollisionGeneration` → +`TryAcquireCollisionPrefixMutationPermission` (never resolved within 200 +ticks in that configuration) — worth a future look if it turns out to matter +in production, but not needed to prove Step 3a. The shipped test instead +holds a real admission open on a DIFFERENT (neighbor) landblock the player +does not target, cancelling rather than committing it — a faithful, simpler +proof of "an open admission anywhere in the plan blocks driving" without +touching that separate question. + +**End-to-end (live ACE, `jump-probe` policy, `ACDREAM_PROBE_PARK=1`), three +runs, all consistent:** hydration now succeeds — `entityCount` reaches 136 at +the `running-stop` resource sample (previously 0, permanently), `[jump-probe] +local player present; charging jump` fires promptly, `seal-refused` spam is +gone, and the original fork-1 crash (`SuspendObjectUpdate` on a dormant +controller) never recurs. **Full pass criterion 4 +(`[jump-probe] airborne-transition False -> True`) was NOT independently +observed on the unmodified tree** — every real run hit a SEPARATE, +newly-discovered, pre-existing defect first (filed as issue #368: the +headless scheduler's `await Task.Delay(...).ConfigureAwait(false)` loop can +resume ticks on a different ThreadPool thread than the one that opened a +collision generation, tripping `RuntimePhysicsState.EnsureCollisionMutationThread`). +#368 is explicitly out of scope for this fix — orthogonal mechanism, no +mention anywhere in this diagnosis, and a proper fix needs verification +against the graphical host, which this session was constrained not to +launch. A throwaway, never-committed diagnostic run with #368's guard +neutralized (verified via `git diff` to have zero residual footprint) DID +reach `[jump-probe] releasing jump (fire)` with a clean exit (code 0, +graceful logout, `entityCount=136`), confirming the #365 mechanism itself is +sound; it timed out waiting for `airborne-transition True` in THAT run, +plausibly a downstream artifact of the same unsynchronized-thread condition +the neutralized guard exists to catch (racing collision/physics state across +threads) rather than a second #365-scope defect — flagged as an open +question in #368, not claimed as resolved. + +Every real (unmodified) run's session tore down gracefully +(`[session] graceful logout confirmed`, zero entities/leases at the final +`disposed` sample) regardless of which way it exited — `testaccount` was +never left in a stuck state by this work. diff --git a/src/AcDream.Headless/Hosting/HeadlessLocalPlayerFrameHost.cs b/src/AcDream.Headless/Hosting/HeadlessLocalPlayerFrameHost.cs index d8c649b3..bafa7a94 100644 --- a/src/AcDream.Headless/Hosting/HeadlessLocalPlayerFrameHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessLocalPlayerFrameHost.cs @@ -39,9 +39,18 @@ internal sealed class HeadlessLocalPlayerFrameHost static (_, _, _, _, _, _) => { }); } + // #365 Step 4: the graphical host gates on IsRuntimePublished + // (PlayerModeAutoEntry.cs C3c-F2); this host only checked for a + // non-null controller, which is also true for a DORMANT (constructed + // but not yet activated) controller and let live-movement operations + // reach it — SuspendObjectUpdate's EnsurePublishedForRuntimeOperation + // throw. CanExecuteLiveMovement is the #356 public lifecycle-caller + // idiom for exactly this: false before publication and after + // retirement, neither of which is an error to skip. public bool CanAdvancePlayer => _runtime.Session.IsInWorld - && _runtime.MovementOwner.Controller is not null; + && _runtime.MovementOwner.Controller + is { CanExecuteLiveMovement: true }; public PlayerMovementController? Controller => _runtime.MovementOwner.Controller; diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs index 4008d259..3f4eec94 100644 --- a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs @@ -42,7 +42,8 @@ internal sealed class HeadlessProcessHost : IDisposable throw new HeadlessConfigurationException( "Direct credentials require exactly one configured session."); } - HeadlessStaticStateAudit.ValidateProcessIsolation(); + HeadlessStaticStateAudit.ValidateProcessIsolation( + configuration.Sessions.Count); _diagnostics = new HeadlessDiagnosticWriter(diagnostics); var credentials = new HeadlessCredentialResolver( diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs index 66d40639..5665ffa6 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionWorldProjection.cs @@ -26,6 +26,18 @@ internal interface IHeadlessCollisionNeighborhood /// placement: its collision-generation wake could never fire. /// bool IsWithinServiceWindow(uint fullCellId); + + /// + /// #365 Step 3a: true when this neighborhood holds NO open collision + /// admission and NO in-flight publication/cancellation work for any + /// landblock in its plan. Driving the first-entry conductor while this + /// is false is a guaranteed TrySealCollisionEvaluationAuthority + /// refusal — the neighborhood's own publication for the destination + /// prefix has an admission registered (or is quiescing) for the exact + /// window RuntimePhysicsState.IsCollisionEvaluationPrefixAdmissible + /// requires clear. Read-only; never mutates (unlike ). + /// + bool IsQuiescent { get; } } internal readonly record struct HeadlessCollisionGenerationAdvance( @@ -301,6 +313,16 @@ internal sealed class HeadlessCollisionNeighborhood return dx <= 1 && dy <= 1; } + /// + /// #365 Step 3a: no open publication, no queued work, no in-flight + /// cancellation. See the interface doc for why this must gate driving + /// the first-entry conductor. + /// + public bool IsQuiescent => + _pendingPublication is null + && _publicationQueue.Count == 0 + && !_pendingPublicationCancellation; + public bool IsReady(uint fullCellId) { uint center = CanonicalLandblock(fullCellId); @@ -663,6 +685,13 @@ internal sealed class HeadlessSessionWorldProjection _ = _runtime.EntityObjects .TryConvertInitialResidenceToCellessRoute(record); } + // #365 Step 3a: driving while the neighborhood still holds an open + // admission or in-flight quiescence for its own publication plan is + // a guaranteed seal refusal (IsCollisionEvaluationPrefixAdmissible + // false for the whole window) — the publication owns the collision + // authority this tick, not the conductor. + if (!_collision.IsQuiescent) + return; _firstEntry?.DriveAll(); _acceptedPositionDrive?.Advance(); } @@ -687,6 +716,9 @@ internal sealed class HeadlessSessionWorldProjection _requestedLocalPlayerCell = position.LandblockId; _collision.CenterOn(position.LandblockId); } + // #365 Step 3a: see the matching comment in ProjectSpawn. + if (!_collision.IsQuiescent) + return; _firstEntry?.DriveAll(); _acceptedPositionDrive?.Advance(); } @@ -737,6 +769,14 @@ internal sealed class HeadlessSessionWorldProjection { if (_requestedLocalPlayerCell != 0u) _ = _collision.IsReady(_requestedLocalPlayerCell); + // #365 Step 3a: never drive the conductor while the neighborhood's + // own publication holds the collision authority for this tick — see + // IHeadlessCollisionNeighborhood.IsQuiescent's doc. IsReady above + // still runs unconditionally: it is what ADVANCES the neighborhood's + // publication work each tick (its own mutating side effect), so + // gating it too would make the neighborhood itself never converge. + if (!_collision.IsQuiescent) + return; _firstEntry?.DriveAll(); _acceptedPositionDrive?.Advance(); } diff --git a/src/AcDream.Headless/Hosting/HeadlessStaticStateAudit.cs b/src/AcDream.Headless/Hosting/HeadlessStaticStateAudit.cs index de7a05fd..e056fac5 100644 --- a/src/AcDream.Headless/Hosting/HeadlessStaticStateAudit.cs +++ b/src/AcDream.Headless/Hosting/HeadlessStaticStateAudit.cs @@ -10,9 +10,19 @@ namespace AcDream.Headless.Hosting; /// share one process. Ordinary diagnostics remain session-labelled through /// . /// +/// +/// #365 Step 1: the refusal's rationale is multi-root attribution ambiguity +/// — it does not hold for a process that owns exactly ONE session, since +/// there is no second root to confuse a probe's cursor/last-hit fields +/// with. Refusing anyway made the exact probe built to diagnose the #365 +/// hydration stall (ACDREAM_PROBE_PARK=1) impossible to run against +/// the single-session repro that needed it. A +/// of 1 now logs the enabled probes loudly and proceeds; anything else +/// keeps the original hard refusal, naming every enabled probe. +/// internal static class HeadlessStaticStateAudit { - internal static void ValidateProcessIsolation() + internal static void ValidateProcessIsolation(int sessionCount) { var enabled = new List(); foreach (PropertyInfo property in typeof(PhysicsDiagnostics) @@ -38,12 +48,19 @@ internal static class HeadlessStaticStateAudit if (PhysicsResolveCapture.IsEnabled) enabled.Add(nameof(PhysicsResolveCapture)); - if (enabled.Count != 0) + if (enabled.Count == 0) + return; + + if (sessionCount == 1) { - throw new HeadlessConfigurationException( - "Multi-session headless mode cannot use process-global " - + "physics probes. Disable: " - + string.Join(", ", enabled)); + Console.WriteLine(FormattableString.Invariant( + $"[headless-audit] single-session process — process-global physics probes enabled: {string.Join(", ", enabled)}")); + return; } + + throw new HeadlessConfigurationException( + "Multi-session headless mode cannot use process-global " + + "physics probes. Disable: " + + string.Join(", ", enabled)); } } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFrameController.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFrameController.cs index 738ad582..94549df6 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFrameController.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerFrameController.cs @@ -72,8 +72,18 @@ public sealed class RuntimeLocalPlayerFrameController { _advancedFrame = null; PlayerMovementController? controller = _host.Controller; - if (!_host.CanAdvancePlayer || controller is null) + // #365 Step 4 hardening: a host whose CanAdvancePlayer check is + // looser than "published" (the exact defect this issue fixed for + // the headless host) still cannot reach a dormant controller here — + // contract-preserving for hosts (the graphical one) that already + // gate correctly, since a published controller's + // CanExecuteLiveMovement is always true. + if (!_host.CanAdvancePlayer + || controller is null + || !controller.CanExecuteLiveMovement) + { return; + } if (!float.IsFinite(deltaSeconds) || deltaSeconds <= 0f) { @@ -132,8 +142,18 @@ public sealed class RuntimeLocalPlayerFrameController public void RunPostNetworkCommandPhase() { PlayerMovementController? controller = _host.Controller; - if (!_host.CanAdvancePlayer || controller is null) + // #365 Step 4 hardening: a host whose CanAdvancePlayer check is + // looser than "published" (the exact defect this issue fixed for + // the headless host) still cannot reach a dormant controller here — + // contract-preserving for hosts (the graphical one) that already + // gate correctly, since a published controller's + // CanExecuteLiveMovement is always true. + if (!_host.CanAdvancePlayer + || controller is null + || !controller.CanExecuteLiveMovement) + { return; + } bool hidden = _host.IsHidden; if (_advancedFrame is { } advanced @@ -163,8 +183,13 @@ public sealed class RuntimeLocalPlayerFrameController { frame = default; PlayerMovementController? controller = _host.Controller; - if (!_host.CanAdvancePlayer || controller is null) + // #365 Step 4 hardening: see AdvanceBeforeNetwork's matching comment. + if (!_host.CanAdvancePlayer + || controller is null + || !controller.CanExecuteLiveMovement) + { return false; + } bool hidden = _host.IsHidden; if (_advancedFrame is { } advanced diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs index 4fa15037..1a326e9c 100644 --- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs @@ -1541,6 +1541,276 @@ public sealed class HeadlessSessionHostTests Assert.Equal(0, ownership.CollisionAdmissionCount); } + /// + /// #365 test 3 — the coverage gap the diagnosis doc's Q4 names + /// (HeadlessSessionHostTests.cs:347-475's existing + /// + /// uses , which is trivially + /// ready and never opens a real admission — the production + /// HeadlessCollisionNeighborhood has never been exercised against + /// the first-entry conductor while an admission was genuinely open). + /// The player's OWN landblock is prepared the same proven way every + /// other test in this file does (AddFlatLandblock + + /// SetPosition.BeginCollisionGeneration/ + /// CommitCollisionGeneration) so it is unconditionally resident — + /// instead + /// holds a SEPARATE REAL open on + /// a DIFFERENT landblock, the same shape as one of the OTHER eight + /// landblocks in production's real 3x3 publication plan — proving Step + /// 3a's gate covers "an admission is open ANYWHERE in the plan", not + /// just the center. Must FAIL on the pre-Step-3a tree — verified by + /// temporarily reverting the three IsQuiescent gates in + /// HeadlessSessionWorldProjection.ProjectSpawn/ + /// ProjectPosition/PumpFirstEntry and confirming this test + /// fails (the conductor reaches PublicationCommitted — a non-null + /// dormant controller — while the unrelated admission is still held + /// open, since driving is unconditional pre-fix). + /// + [Fact] + public void RealAdmissionNeverDrivesTheConductorWhileOpenAndHydratesOnceReleased() + { + var operations = new FixtureSessionOperations(); + using var credential = new HeadlessCredentialSecret( + "fixture", + "password"); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(TextWriter.Null), + operations); + GameRuntime runtime = host.Runtime; + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + const uint player = 0x50000012u; + const uint neighborLandblockId = 0xAAB4FFFFu; + runtime.PlayerIdentity.ServerGuid = player; + runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration( + 0xA9B40000u, 1UL); + AddFlatLandblock(runtime.EntityObjects.Physics.Engine); + runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration( + 0xA9B40000u, 1UL, ready: true); + AcDream.Runtime.Session.RuntimeFirstEntryDriveController firstEntry = + CreateFirstEntryDrive(runtime); + RuntimeEntityRecord record = runtime.EntityObjects + .RegisterEntityWithInitialResidence( + Spawn(player), + isLocalPlayer: true) + .Canonical!; + Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn( + record, + record.CreateIntegrationVersion, + record.Snapshot, + replaceGeneration: false)); + + var collision = new NeighborAdmissionHeldOpenCollisionNeighborhood( + runtime.EntityObjects.Physics, + neighborLandblockId); + collision.OpenHeldAdmission(); + var projection = new HeadlessSessionWorldProjection( + runtime, + collision, + firstEntry); + + projection.ProjectSpawn(record, isLocalPlayer: true); + Assert.False(collision.IsQuiescent); + Assert.Null(runtime.MovementOwner.Controller); + + // Hold the SEPARATE admission open across many pumps — far longer + // than the conductor needs to reach mover-prep/placement/ + // PublicationCommitted on the ALREADY-READY player landblock, so a + // pre-fix conductor being undriven only by luck of a short window + // cannot slip through. + for (int tick = 0; tick < 50; tick++) + { + projection.PumpFirstEntry(); + Assert.False(collision.IsQuiescent); + Assert.Null(runtime.MovementOwner.Controller); + } + + collision.ReleaseHeldAdmission(); + Assert.True(collision.IsQuiescent); + const int boundedTicks = 200; + bool published = false; + for (int tick = 0; tick < boundedTicks; tick++) + { + projection.PumpFirstEntry(); + if (runtime.MovementOwner.Controller is { IsRuntimePublished: true }) + { + published = true; + break; + } + } + + Assert.True( + published, + "the local player never reached RuntimePublished within the " + + "bounded tick budget after the unrelated admission cleared."); + PlayerMovementController controller = Assert.IsType< + PlayerMovementController>(runtime.MovementOwner.Controller); + Assert.True(controller.IsRuntimePublished); + Assert.Equal(0, firstEntry.PendingCount); + } + + /// + /// #365 test 4: PumpFirstEntry must not call DriveAll while + /// the collision neighborhood reports non-quiescent, and must call it on + /// the first tick after quiescence — the exact Step 3a gate, isolated + /// from the admission machinery itself via a directly-controllable fake. + /// + [Fact] + public void PumpFirstEntryWithholdsDriveAllUntilQuiescentThenDrivesImmediately() + { + var operations = new FixtureSessionOperations(); + using var credential = new HeadlessCredentialSecret( + "fixture", + "password"); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(TextWriter.Null), + operations); + GameRuntime runtime = host.Runtime; + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + const uint player = 0x50000013u; + runtime.PlayerIdentity.ServerGuid = player; + runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration( + 0xA9B40000u, 1UL); + AddFlatLandblock(runtime.EntityObjects.Physics.Engine); + runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration( + 0xA9B40000u, 1UL, ready: true); + AcDream.Runtime.Session.RuntimeFirstEntryDriveController firstEntry = + CreateFirstEntryDrive(runtime); + RuntimeEntityRecord record = runtime.EntityObjects + .RegisterEntityWithInitialResidence(Spawn(player), isLocalPlayer: true) + .Canonical!; + Assert.True(runtime.EntityObjects.ApplyAcceptedSpawn( + record, + record.CreateIntegrationVersion, + record.Snapshot, + replaceGeneration: false)); + + var collision = new GateControllableCollisionNeighborhood + { + QuiescentOverride = false, + }; + var projection = new HeadlessSessionWorldProjection( + runtime, + collision, + firstEntry); + + projection.ProjectSpawn(record, isLocalPlayer: true); + Assert.Null(runtime.MovementOwner.Controller); + Assert.Equal(1, firstEntry.PendingCount); + + projection.PumpFirstEntry(); + Assert.Null(runtime.MovementOwner.Controller); + Assert.Equal(1, firstEntry.PendingCount); + + collision.QuiescentOverride = true; + projection.PumpFirstEntry(); + + Assert.NotNull(runtime.MovementOwner.Controller); + } + + /// + /// #365 test 5: HeadlessLocalPlayerFrameHost.CanAdvancePlayer + /// tracks the controller's exact publication lifecycle — false while + /// dormant (the crash bug's shape), true once published, false again + /// once retired (the #356 lifecycle-caller idiom). + /// + [Fact] + public void CanAdvancePlayerReflectsControllerPublicationLifecycle() + { + var operations = new FixtureSessionOperations(); + using var credential = new HeadlessCredentialSecret( + "fixture", + "password"); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(TextWriter.Null), + operations); + GameRuntime runtime = host.Runtime; + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + Assert.True(runtime.Session.IsInWorld); + + var inertSession = CreateInertLiveSessionHost(); + var frameHost = new HeadlessLocalPlayerFrameHost(runtime, inertSession); + + PlayerMovementController candidate = + PlayerMovementController.CreatePublicationCandidate( + new PhysicsEngine(), + PlayerMovementConstructionOptions.Fallback); + candidate.SealPublicationCandidate(); + candidate.CommitRuntimeOwnership(new RetailObjectQuantumClock()); + runtime.MovementOwner.Controller = candidate; + Assert.True(candidate.IsRuntimeOwnedDormant); + Assert.False(frameHost.CanAdvancePlayer); + + candidate.ActivateRuntimePublication(); + Assert.True(candidate.IsRuntimePublished); + Assert.True(frameHost.CanAdvancePlayer); + + candidate.RetireRuntimePublication(); + Assert.False(frameHost.CanAdvancePlayer); + } + + private static LiveSessionHost CreateInertLiveSessionHost() + { + var controller = new LiveSessionController( + new ThrowingLiveSessionOperations()); + return new LiveSessionHost( + controller, + new LiveSessionHostBindings( + new LiveSessionRoutingFactories( + _ => throw new NotSupportedException(), + _ => throw new NotSupportedException()), + _ => { }, + new LiveSessionSelectionBindings( + _ => { }, + _ => { }, + _ => { }, + _ => { }, + _ => { }, + () => { }), + new LiveSessionEnteredWorldBindings( + _ => { }, + () => { }, + () => { }, + _ => { }, + () => { }), + (_, _, _) => { }, + () => { })); + } + + private sealed class ThrowingLiveSessionOperations : ILiveSessionOperations + { + public IPEndPoint ResolveEndpoint(string host, int port) => + throw new NotSupportedException(); + public WorldSession CreateSession(IPEndPoint endpoint) => + throw new NotSupportedException(); + public void Connect( + WorldSession session, + string user, + string password) => + throw new NotSupportedException(); + public CharacterList.Parsed? GetCharacters(WorldSession session) => + throw new NotSupportedException(); + public void EnterWorld( + WorldSession session, + int activeCharacterIndex) => + throw new NotSupportedException(); + public void Tick(WorldSession session) => + throw new NotSupportedException(); + public void DisposeSession(WorldSession session) => + throw new NotSupportedException(); + } + private static HeadlessSessionDescriptor Descriptor( HeadlessCredentialProviderKind provider = HeadlessCredentialProviderKind.Environment, @@ -2265,6 +2535,12 @@ public sealed class HeadlessSessionHostTests public bool IsReady(uint fullCellId) => fullCellId == LastCell; + // #365 Step 3a: this fixture has no real admission/publication + // machinery to hold open — every CenterOn/IsReady call is a + // synchronous no-op, so it is quiescent by construction. A fake + // that ever wants to exercise the gate should override this. + public bool IsQuiescent => true; + // C3c-R1 review F7: the fixture window mirrors production's 3x3 // membership around the last requested center; no center yet means // "within" (never convert before the first CenterOn). @@ -2282,6 +2558,79 @@ public sealed class HeadlessSessionHostTests } } + /// + /// #365 test 3 support: unlike + /// (trivially ready, never opens a real admission), + /// + /// needs a fake that holds an ACTUAL + /// open on a landblock the local player does NOT target — the same + /// per-landblock admission primitive production's + /// HeadlessCollisionNeighborhood.CreatePublication opens for every + /// entry in its 3x3 plan. Cancelling (never committing) sidesteps + /// CommitCollisionGeneration's own multi-tick + /// TryAcquireCollisionPrefixMutationPermission settlement — this + /// fake only needs to prove the SEAL sees an open admission, not drive a + /// second full commit cycle to completion. + /// + private sealed class NeighborAdmissionHeldOpenCollisionNeighborhood( + RuntimePhysicsState physics, + uint heldLandblockId) : IHeadlessCollisionNeighborhood + { + private RuntimeCollisionAdmission? _admission; + private PreparedLandblockCollisionGeneration? _prepared; + + internal void OpenHeldAdmission() + { + _admission = physics.BeginCollisionAdmission(heldLandblockId); + _prepared = physics.PrepareCollisionGeneration(_admission); + } + + internal void ReleaseHeldAdmission() + { + if (_admission is null) + return; + bool cancelled = physics.CancelCollisionGeneration( + _admission, + _prepared); + Assert.True( + cancelled, + "the held-open neighbor admission did not cancel in one call."); + _admission = null; + _prepared = null; + } + + public bool IsQuiescent => _admission is null; + + public void CenterOn(uint fullCellId) + { + } + + public bool IsReady(uint fullCellId) => true; + + public bool IsWithinServiceWindow(uint fullCellId) => true; + } + + /// + /// #365 test 4 support: a fake whose the test + /// flips directly, isolating PumpFirstEntry's gate from any real + /// admission machinery. + /// + private sealed class GateControllableCollisionNeighborhood + : IHeadlessCollisionNeighborhood + { + private uint _lastCell; + + internal bool QuiescentOverride { get; set; } = true; + + public void CenterOn(uint fullCellId) => _lastCell = fullCellId; + + public bool IsReady(uint fullCellId) => fullCellId == _lastCell; + + public bool IsWithinServiceWindow(uint fullCellId) => true; + + public bool IsQuiescent => QuiescentOverride; + } + private sealed class FixtureEventRoute( Action? onDispose = null) : ILiveSessionEventRouting { diff --git a/tests/AcDream.Headless.Tests/HeadlessStaticStateAuditTests.cs b/tests/AcDream.Headless.Tests/HeadlessStaticStateAuditTests.cs new file mode 100644 index 00000000..19d3dba1 --- /dev/null +++ b/tests/AcDream.Headless.Tests/HeadlessStaticStateAuditTests.cs @@ -0,0 +1,95 @@ +using AcDream.Core.Physics; +using AcDream.Headless.Configuration; +using AcDream.Headless.Hosting; + +namespace AcDream.Headless.Tests; + +/// +/// #365 Step 1: the audit's refusal rationale is multi-root attribution +/// ambiguity, which does not hold for a process that owns exactly one +/// session. These tests mutate process- +/// global probe flags and swap , so they run in +/// their own non-parallel collection — see +/// . +/// +[CollectionDefinition( + HeadlessStaticStateAuditCollection.Name, + DisableParallelization = true)] +public sealed class HeadlessStaticStateAuditCollection +{ + public const string Name = "Headless static-state audit"; +} + +[Collection(HeadlessStaticStateAuditCollection.Name)] +public sealed class HeadlessStaticStateAuditTests : IDisposable +{ + public HeadlessStaticStateAuditTests() => PhysicsDiagnostics.ResetForTest(); + + public void Dispose() => PhysicsDiagnostics.ResetForTest(); + + [Fact] + public void SingleSessionWithProbeEnabledIsAllowedAndLoggedLoudly() + { + PhysicsDiagnostics.ProbeParkEnabled = true; + var originalOut = Console.Out; + using var captured = new StringWriter(); + Console.SetOut(captured); + try + { + HeadlessStaticStateAudit.ValidateProcessIsolation(sessionCount: 1); + } + finally + { + Console.SetOut(originalOut); + } + + Assert.Contains( + nameof(PhysicsDiagnostics.ProbeParkEnabled), + captured.ToString(), + StringComparison.Ordinal); + } + + [Fact] + public void SingleSessionWithNoProbesEnabledIsSilent() + { + var originalOut = Console.Out; + using var captured = new StringWriter(); + Console.SetOut(captured); + try + { + HeadlessStaticStateAudit.ValidateProcessIsolation(sessionCount: 1); + } + finally + { + Console.SetOut(originalOut); + } + + Assert.Equal(string.Empty, captured.ToString()); + } + + [Fact] + public void MultiSessionWithProbeEnabledStillThrowsNamingTheProbe() + { + PhysicsDiagnostics.ProbeParkEnabled = true; + + HeadlessConfigurationException exception = Assert.Throws< + HeadlessConfigurationException>( + () => HeadlessStaticStateAudit.ValidateProcessIsolation( + sessionCount: 2)); + + Assert.Contains( + nameof(PhysicsDiagnostics.ProbeParkEnabled), + exception.Message, + StringComparison.Ordinal); + } + + [Fact] + public void MultiSessionWithNoProbesEnabledIsAllowed() + { + Exception? exception = Record.Exception( + () => HeadlessStaticStateAudit.ValidateProcessIsolation( + sessionCount: 3)); + + Assert.Null(exception); + } +} diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFrameControllerTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFrameControllerTests.cs new file mode 100644 index 00000000..05b4a05d --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerFrameControllerTests.cs @@ -0,0 +1,154 @@ +using AcDream.Core.Physics; +using AcDream.Runtime.Gameplay; + +namespace AcDream.Runtime.Tests.Gameplay; + +/// +/// #365 Step 4: must never +/// advance a DORMANT (constructed but not yet ActivateRuntimePublication +/// -ed) controller's live-movement operations, even if a host's own +/// CanAdvancePlayer is wrong (the exact headless defect — it only +/// checked Controller is not null, true for a dormant controller too). +/// These tests deliberately SABOTAGE the host's CanAdvancePlayer to +/// true to prove the controller's own +/// gate is the +/// one holding the line, not the host's contract. +/// +public sealed class RuntimeLocalPlayerFrameControllerTests +{ + [Fact] + public void AdvanceBeforeNetworkDoesNotThrowOnDormantControllerInSuspendDisposition() + { + PlayerMovementController dormant = CreateDormantController(); + var host = new SabotagingHost( + dormant, + RetailObjectClockDisposition.Suspend); + var controller = new RuntimeLocalPlayerFrameController( + host, + new FixedMovementInputSource()); + + Exception? exception = Record.Exception( + () => controller.AdvanceBeforeNetwork(0.015f)); + + Assert.Null(exception); + Assert.Equal(0, host.ProjectCallCount); + } + + [Fact] + public void AdvanceBeforeNetworkDoesNotThrowOnDormantControllerInAdvanceDisposition() + { + PlayerMovementController dormant = CreateDormantController(); + var host = new SabotagingHost( + dormant, + RetailObjectClockDisposition.Advance); + var controller = new RuntimeLocalPlayerFrameController( + host, + new FixedMovementInputSource()); + + Exception? exception = Record.Exception( + () => controller.AdvanceBeforeNetwork(0.015f)); + + Assert.Null(exception); + Assert.Equal(0, host.ProjectCallCount); + } + + [Fact] + public void RunPostNetworkCommandPhaseDoesNotThrowOnDormantController() + { + PlayerMovementController dormant = CreateDormantController(); + var host = new SabotagingHost( + dormant, + RetailObjectClockDisposition.Advance); + var controller = new RuntimeLocalPlayerFrameController( + host, + new FixedMovementInputSource()); + + Exception? exception = Record.Exception( + controller.RunPostNetworkCommandPhase); + + Assert.Null(exception); + Assert.Equal(0, host.SendPostNetworkCallCount); + } + + [Fact] + public void TryGetPresentationAfterNetworkReturnsFalseOnDormantControllerWithoutThrowing() + { + PlayerMovementController dormant = CreateDormantController(); + var host = new SabotagingHost( + dormant, + RetailObjectClockDisposition.Advance); + var controller = new RuntimeLocalPlayerFrameController( + host, + new FixedMovementInputSource()); + + bool result = false; + Exception? exception = Record.Exception(() => + result = controller.TryGetPresentationAfterNetwork( + out RuntimeLocalPlayerPresentationFrame _)); + + Assert.Null(exception); + Assert.False(result); + } + + private static PlayerMovementController CreateDormantController() + { + PlayerMovementController candidate = + PlayerMovementController.CreatePublicationCandidate( + new PhysicsEngine(), + PlayerMovementConstructionOptions.Fallback); + candidate.SealPublicationCandidate(); + candidate.CommitRuntimeOwnership(new RetailObjectQuantumClock()); + Assert.True(candidate.IsRuntimeOwnedDormant); + Assert.False(candidate.CanExecuteLiveMovement); + return candidate; + } + + private sealed class FixedMovementInputSource : IRuntimeMovementInputSource + { + public MovementInput Capture() => default; + } + + /// + /// A host that reports CanAdvancePlayer: true unconditionally — + /// the exact pre-fix headless bug shape — regardless of the wired + /// controller's real publication state. + /// + private sealed class SabotagingHost( + PlayerMovementController? controller, + RetailObjectClockDisposition disposition) + : IRuntimeLocalPlayerFrameHost + { + internal int ProjectCallCount { get; private set; } + internal int SendPostNetworkCallCount { get; private set; } + + public bool CanAdvancePlayer => true; + public PlayerMovementController? Controller => controller; + + public uint ResolveLocalEntityId() => 1u; + public void HandleTargeting() + { + } + + public bool IsHidden => false; + public RetailObjectClockDisposition ObjectClockDisposition => + disposition; + + public void Project( + PlayerMovementController controller, + MovementResult movement, + bool hidden) => + ProjectCallCount++; + + public void SendPreNetwork( + PlayerMovementController controller, + MovementResult movement, + bool hidden) + { + } + + public void SendPostNetwork( + PlayerMovementController controller, + bool hidden) => + SendPostNetworkCallCount++; + } +}