fix(headless): #365 — collision-admission-open window drove the first-entry conductor into a permanent seal refusal

Root cause (measured live via ACDREAM_PROBE_PARK=1): HeadlessSessionWorldProjection
drove the first-entry conductor unconditionally, including while
HeadlessCollisionNeighborhood's own 3x3 publication plan held a genuinely open
RuntimeCollisionAdmission for the local player's landblock. Every
TrySealCollisionEvaluationAuthority attempt during that window failed
(IsCollisionEvaluationPrefixAdmissible false) and retried forever without
recovering — measured verdict: "seal-refused" repeating with no preceding
[rearm] verdict= line (the operation never even reached the AwaitingCell park).
This is the diagnosis doc's "structural half" mechanism; no evidence of the
"circular HasOldPrefixPlacementDebt" hypothesis was observed, so that shape
was not needed.

Step 1 (enabler): HeadlessStaticStateAudit.ValidateProcessIsolation now takes
sessionCount and only refuses process-global physics probes for
sessionCount > 1 — its own multi-root-attribution rationale never applied to
a single session, and it was blocking the exact probe built to diagnose this
class of stall.

Step 3a (root cause): new IHeadlessCollisionNeighborhood.IsQuiescent gates
ProjectSpawn/ProjectPosition/PumpFirstEntry's conductor-drive calls — the
conductor is never driven while the neighborhood's own publication owns
collision authority for that tick.

Step 4 (defense-in-depth): HeadlessLocalPlayerFrameHost.CanAdvancePlayer now
requires Controller.CanExecuteLiveMovement instead of just a non-null
controller — the headless-only gap that turned the (now-fixed) hydration
stall into a hard crash reaching SuspendObjectUpdate on a dormant controller.
RuntimeLocalPlayerFrameController's three shared entry points gained the same
guard, contract-preserving for the graphical host.

Verified end-to-end against live ACE (jump-probe policy, three runs):
hydration succeeds cleanly (136 entities load vs. 0 before), no seal-refused
spam, no crash from the original bug, graceful logout every time. Full
airborne-transition confirmation is blocked by a separate, newly-discovered,
pre-existing defect filed as #368 (the headless scheduler's
Task.Delay(...).ConfigureAwait(false) tick loop can resume on a different
ThreadPool thread mid collision-generation, tripping
EnsureCollisionMutationThread) — explicitly out of scope here, not mentioned
anywhere in the #365 diagnosis, and unsafe to fix without graphical-host
verification this session was constrained not to perform.

New tests: the real-admission hydration test (fails on the pre-Step-3a tree,
verified by temporarily reverting the three gates and confirming failure,
then restoring), the PumpFirstEntry quiescence-gate test, the
CanAdvancePlayer publication-lifecycle test, the dormant-controller
sabotage tests for RuntimeLocalPlayerFrameController, and the audit
single/multi-session tests. RuntimeLocalPlayerPhysicsPublicationStateTests
is untouched.

Full Release suite: 12,343 passed / 4 skipped / 0 failed (baseline ~12,330/4
plus 11 new tests).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-10 11:34:36 +02:00
parent 6150327ea3
commit 41b408f3e6
10 changed files with 937 additions and 35 deletions

View file

@ -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 <cfg>` 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 K1K4'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

View file

@ -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.