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

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