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.

View file

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

View file

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

View file

@ -26,6 +26,18 @@ internal interface IHeadlessCollisionNeighborhood
/// placement: its collision-generation wake could never fire.
/// </summary>
bool IsWithinServiceWindow(uint fullCellId);
/// <summary>
/// #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 <c>TrySealCollisionEvaluationAuthority</c>
/// refusal — the neighborhood's own publication for the destination
/// prefix has an admission registered (or is quiescing) for the exact
/// window <c>RuntimePhysicsState.IsCollisionEvaluationPrefixAdmissible</c>
/// requires clear. Read-only; never mutates (unlike <see cref="IsReady"/>).
/// </summary>
bool IsQuiescent { get; }
}
internal readonly record struct HeadlessCollisionGenerationAdvance(
@ -301,6 +313,16 @@ internal sealed class HeadlessCollisionNeighborhood
return dx <= 1 && dy <= 1;
}
/// <summary>
/// #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.
/// </summary>
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();
}

View file

@ -10,9 +10,19 @@ namespace AcDream.Headless.Hosting;
/// share one process. Ordinary diagnostics remain session-labelled through
/// <see cref="Diagnostics.HeadlessDiagnosticWriter"/>.
/// </summary>
/// <remarks>
/// #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 (<c>ACDREAM_PROBE_PARK=1</c>) impossible to run against
/// the single-session repro that needed it. A <paramref name="sessionCount"/>
/// of 1 now logs the enabled probes loudly and proceeds; anything else
/// keeps the original hard refusal, naming every enabled probe.
/// </remarks>
internal static class HeadlessStaticStateAudit
{
internal static void ValidateProcessIsolation()
internal static void ValidateProcessIsolation(int sessionCount)
{
var enabled = new List<string>();
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));
}
}

View file

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

View file

@ -1541,6 +1541,276 @@ public sealed class HeadlessSessionHostTests
Assert.Equal(0, ownership.CollisionAdmissionCount);
}
/// <summary>
/// #365 test 3 — the coverage gap the diagnosis doc's Q4 names
/// (<c>HeadlessSessionHostTests.cs:347-475</c>'s existing
/// <see cref="WorldProjectionHydratesCanonicalMovementAndTeleportState"/>
/// uses <see cref="FixtureCollisionNeighborhood"/>, which is trivially
/// ready and never opens a real admission — the production
/// <c>HeadlessCollisionNeighborhood</c> 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 (<c>AddFlatLandblock</c> +
/// <c>SetPosition.BeginCollisionGeneration</c>/
/// <c>CommitCollisionGeneration</c>) so it is unconditionally resident —
/// <see cref="NeighborAdmissionHeldOpenCollisionNeighborhood"/> instead
/// holds a SEPARATE REAL <see cref="RuntimeCollisionAdmission"/> 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 <c>IsQuiescent</c> gates in
/// <c>HeadlessSessionWorldProjection.ProjectSpawn</c>/
/// <c>ProjectPosition</c>/<c>PumpFirstEntry</c> and confirming this test
/// fails (the conductor reaches <c>PublicationCommitted</c> — a non-null
/// dormant controller — while the unrelated admission is still held
/// open, since driving is unconditional pre-fix).
/// </summary>
[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);
}
/// <summary>
/// #365 test 4: <c>PumpFirstEntry</c> must not call <c>DriveAll</c> 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.
/// </summary>
[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);
}
/// <summary>
/// #365 test 5: <c>HeadlessLocalPlayerFrameHost.CanAdvancePlayer</c>
/// 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).
/// </summary>
[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
}
}
/// <summary>
/// #365 test 3 support: unlike <see cref="FixtureCollisionNeighborhood"/>
/// (trivially ready, never opens a real admission),
/// <see cref="RealAdmissionNeverDrivesTheConductorWhileOpenAndHydratesOnceReleased"/>
/// needs a fake that holds an ACTUAL <see cref="RuntimeCollisionAdmission"/>
/// open on a landblock the local player does NOT target — the same
/// per-landblock admission primitive production's
/// <c>HeadlessCollisionNeighborhood.CreatePublication</c> opens for every
/// entry in its 3x3 plan. Cancelling (never committing) sidesteps
/// <c>CommitCollisionGeneration</c>'s own multi-tick
/// <c>TryAcquireCollisionPrefixMutationPermission</c> settlement — this
/// fake only needs to prove the SEAL sees an open admission, not drive a
/// second full commit cycle to completion.
/// </summary>
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;
}
/// <summary>
/// #365 test 4 support: a fake whose <see cref="IsQuiescent"/> the test
/// flips directly, isolating <c>PumpFirstEntry</c>'s gate from any real
/// admission machinery.
/// </summary>
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
{

View file

@ -0,0 +1,95 @@
using AcDream.Core.Physics;
using AcDream.Headless.Configuration;
using AcDream.Headless.Hosting;
namespace AcDream.Headless.Tests;
/// <summary>
/// #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 <see cref="PhysicsDiagnostics"/> process-
/// global probe flags and swap <see cref="Console.Out"/>, so they run in
/// their own non-parallel collection — see
/// <see cref="HeadlessStaticStateAuditCollection"/>.
/// </summary>
[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);
}
}

View file

@ -0,0 +1,154 @@
using AcDream.Core.Physics;
using AcDream.Runtime.Gameplay;
namespace AcDream.Runtime.Tests.Gameplay;
/// <summary>
/// #365 Step 4: <see cref="RuntimeLocalPlayerFrameController"/> must never
/// advance a DORMANT (constructed but not yet <c>ActivateRuntimePublication</c>
/// -ed) controller's live-movement operations, even if a host's own
/// <c>CanAdvancePlayer</c> is wrong (the exact headless defect — it only
/// checked <c>Controller is not null</c>, true for a dormant controller too).
/// These tests deliberately SABOTAGE the host's <c>CanAdvancePlayer</c> to
/// <c>true</c> to prove the controller's own
/// <see cref="PlayerMovementController.CanExecuteLiveMovement"/> gate is the
/// one holding the line, not the host's contract.
/// </summary>
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;
}
/// <summary>
/// A host that reports <c>CanAdvancePlayer: true</c> unconditionally —
/// the exact pre-fix headless bug shape — regardless of the wired
/// controller's real publication state.
/// </summary>
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++;
}
}