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:
parent
6150327ea3
commit
41b408f3e6
10 changed files with 937 additions and 35 deletions
166
docs/ISSUES.md
166
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
|
## #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
|
**Status:** CLOSED 2026-08-10. Root-caused and fixed per
|
||||||
reproduction. Distinct from #330 (no collision) and #332 (no remote DR):
|
`docs/research/2026-08-10-365-headless-hydration-diagnosis.md` (see its
|
||||||
this is the LOCAL player.
|
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,
|
**Correction to the evidence chain below:** `entities: 0` in the headless
|
||||||
`testaccount`/`+Acdream`) reaches `InWorld`, `live: in world — CreateObject
|
JSON is NOT evidence of failed hydration. `HeadlessDiagnosticWriter.Lifecycle`
|
||||||
stream active` prints, but `entityCount` stays **0** for the whole run and
|
fires at exactly four points (constructed / start-result / reconnect-deferred
|
||||||
the local movement controller is never published. The first
|
/ stopped), none of which run after a CreateObject stream has had any chance
|
||||||
`SetIntent(Jump: true)` → next `AdvanceBeforeNetwork` tick quarantined the
|
to populate the entity table — a perfectly healthy run prints the SAME
|
||||||
session.
|
`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`
|
1. First quarantine: `RuntimeLocalPlayerFrameController.AdvanceBeforeNetwork:93`
|
||||||
unconditionally re-assigned `controller.LocalEntityId` — a sealed
|
unconditionally re-assigned `controller.LocalEntityId` — a sealed
|
||||||
configuration property — on a still-dormant controller. FIXED in the
|
configuration property — on a still-dormant controller. FIXED in the
|
||||||
|
|
@ -144,16 +207,8 @@ session.
|
||||||
`ObjectClockDisposition == Suspend` branch and calls
|
`ObjectClockDisposition == Suspend` branch and calls
|
||||||
`SuspendObjectUpdate` → `EnsurePublishedForRuntimeOperation` throws —
|
`SuspendObjectUpdate` → `EnsurePublishedForRuntimeOperation` throws —
|
||||||
`_host.CanAdvancePlayer` is true while the controller is unpublished.
|
`_host.CanAdvancePlayer` is true while the controller is unpublished.
|
||||||
3. Root condition: the session's world never hydrates (`entities: 0`
|
3. Root condition (at the time): believed to be "the session's world never
|
||||||
despite the active CreateObject stream), so dormant local activation
|
hydrates" from the `entities: 0` artifact — corrected above.
|
||||||
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).
|
|
||||||
|
|
||||||
**Repro:** `dotnet run --project src/AcDream.Headless -c Release -- run
|
**Repro:** `dotnet run --project src/AcDream.Headless -c Release -- run
|
||||||
--config <cfg>` with a `jump-probe` policy session against local ACE
|
--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`
|
Environment). `HeadlessDiagnosticWriter.Failure` now emits `errorDetail`
|
||||||
(full exception) — added during this diagnosis.
|
(full exception) — added during this diagnosis.
|
||||||
|
|
||||||
**Next step:** bisect the headless hydration path (why zero entities admit
|
## #368 — Headless scheduler's async tick loop can run collision-generation calls on different threads, tripping `EnsureCollisionMutationThread`
|
||||||
headless when the graphical host hydrates fine) BEFORE touching
|
|
||||||
`CanAdvancePlayer` — the #357 closeout warns that dormant-activation
|
**Status:** OPEN — filed 2026-08-10 during #365's end-to-end verification.
|
||||||
classification changes have a strict test matrix.
|
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
|
## #366 — Chat window's new-unseen-text indicator (0x1000048C) imports but is never independently wired
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/ |
|
| 529e0e9d root cause; 78b981cc changed the symptom | High | git show 529e0e9d -- src/AcDream.Headless/ |
|
||||||
| Probes unrunnable headless | Certain | HeadlessProcessHost.cs:45 |
|
| Probes unrunnable headless | Certain | HeadlessProcessHost.cs:45 |
|
||||||
| Existing hydration test bypasses admission/seal | Certain | HeadlessSessionHostTests.cs:372-375 |
|
| 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.
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,18 @@ internal sealed class HeadlessLocalPlayerFrameHost
|
||||||
static (_, _, _, _, _, _) => { });
|
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 =>
|
public bool CanAdvancePlayer =>
|
||||||
_runtime.Session.IsInWorld
|
_runtime.Session.IsInWorld
|
||||||
&& _runtime.MovementOwner.Controller is not null;
|
&& _runtime.MovementOwner.Controller
|
||||||
|
is { CanExecuteLiveMovement: true };
|
||||||
|
|
||||||
public PlayerMovementController? Controller =>
|
public PlayerMovementController? Controller =>
|
||||||
_runtime.MovementOwner.Controller;
|
_runtime.MovementOwner.Controller;
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,8 @@ internal sealed class HeadlessProcessHost : IDisposable
|
||||||
throw new HeadlessConfigurationException(
|
throw new HeadlessConfigurationException(
|
||||||
"Direct credentials require exactly one configured session.");
|
"Direct credentials require exactly one configured session.");
|
||||||
}
|
}
|
||||||
HeadlessStaticStateAudit.ValidateProcessIsolation();
|
HeadlessStaticStateAudit.ValidateProcessIsolation(
|
||||||
|
configuration.Sessions.Count);
|
||||||
|
|
||||||
_diagnostics = new HeadlessDiagnosticWriter(diagnostics);
|
_diagnostics = new HeadlessDiagnosticWriter(diagnostics);
|
||||||
var credentials = new HeadlessCredentialResolver(
|
var credentials = new HeadlessCredentialResolver(
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,18 @@ internal interface IHeadlessCollisionNeighborhood
|
||||||
/// placement: its collision-generation wake could never fire.
|
/// placement: its collision-generation wake could never fire.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
bool IsWithinServiceWindow(uint fullCellId);
|
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(
|
internal readonly record struct HeadlessCollisionGenerationAdvance(
|
||||||
|
|
@ -301,6 +313,16 @@ internal sealed class HeadlessCollisionNeighborhood
|
||||||
return dx <= 1 && dy <= 1;
|
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)
|
public bool IsReady(uint fullCellId)
|
||||||
{
|
{
|
||||||
uint center = CanonicalLandblock(fullCellId);
|
uint center = CanonicalLandblock(fullCellId);
|
||||||
|
|
@ -663,6 +685,13 @@ internal sealed class HeadlessSessionWorldProjection
|
||||||
_ = _runtime.EntityObjects
|
_ = _runtime.EntityObjects
|
||||||
.TryConvertInitialResidenceToCellessRoute(record);
|
.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();
|
_firstEntry?.DriveAll();
|
||||||
_acceptedPositionDrive?.Advance();
|
_acceptedPositionDrive?.Advance();
|
||||||
}
|
}
|
||||||
|
|
@ -687,6 +716,9 @@ internal sealed class HeadlessSessionWorldProjection
|
||||||
_requestedLocalPlayerCell = position.LandblockId;
|
_requestedLocalPlayerCell = position.LandblockId;
|
||||||
_collision.CenterOn(position.LandblockId);
|
_collision.CenterOn(position.LandblockId);
|
||||||
}
|
}
|
||||||
|
// #365 Step 3a: see the matching comment in ProjectSpawn.
|
||||||
|
if (!_collision.IsQuiescent)
|
||||||
|
return;
|
||||||
_firstEntry?.DriveAll();
|
_firstEntry?.DriveAll();
|
||||||
_acceptedPositionDrive?.Advance();
|
_acceptedPositionDrive?.Advance();
|
||||||
}
|
}
|
||||||
|
|
@ -737,6 +769,14 @@ internal sealed class HeadlessSessionWorldProjection
|
||||||
{
|
{
|
||||||
if (_requestedLocalPlayerCell != 0u)
|
if (_requestedLocalPlayerCell != 0u)
|
||||||
_ = _collision.IsReady(_requestedLocalPlayerCell);
|
_ = _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();
|
_firstEntry?.DriveAll();
|
||||||
_acceptedPositionDrive?.Advance();
|
_acceptedPositionDrive?.Advance();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,19 @@ namespace AcDream.Headless.Hosting;
|
||||||
/// share one process. Ordinary diagnostics remain session-labelled through
|
/// share one process. Ordinary diagnostics remain session-labelled through
|
||||||
/// <see cref="Diagnostics.HeadlessDiagnosticWriter"/>.
|
/// <see cref="Diagnostics.HeadlessDiagnosticWriter"/>.
|
||||||
/// </summary>
|
/// </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 class HeadlessStaticStateAudit
|
||||||
{
|
{
|
||||||
internal static void ValidateProcessIsolation()
|
internal static void ValidateProcessIsolation(int sessionCount)
|
||||||
{
|
{
|
||||||
var enabled = new List<string>();
|
var enabled = new List<string>();
|
||||||
foreach (PropertyInfo property in typeof(PhysicsDiagnostics)
|
foreach (PropertyInfo property in typeof(PhysicsDiagnostics)
|
||||||
|
|
@ -38,12 +48,19 @@ internal static class HeadlessStaticStateAudit
|
||||||
if (PhysicsResolveCapture.IsEnabled)
|
if (PhysicsResolveCapture.IsEnabled)
|
||||||
enabled.Add(nameof(PhysicsResolveCapture));
|
enabled.Add(nameof(PhysicsResolveCapture));
|
||||||
|
|
||||||
if (enabled.Count != 0)
|
if (enabled.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (sessionCount == 1)
|
||||||
{
|
{
|
||||||
throw new HeadlessConfigurationException(
|
Console.WriteLine(FormattableString.Invariant(
|
||||||
"Multi-session headless mode cannot use process-global "
|
$"[headless-audit] single-session process — process-global physics probes enabled: {string.Join(", ", enabled)}"));
|
||||||
+ "physics probes. Disable: "
|
return;
|
||||||
+ string.Join(", ", enabled));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw new HeadlessConfigurationException(
|
||||||
|
"Multi-session headless mode cannot use process-global "
|
||||||
|
+ "physics probes. Disable: "
|
||||||
|
+ string.Join(", ", enabled));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,8 +72,18 @@ public sealed class RuntimeLocalPlayerFrameController
|
||||||
{
|
{
|
||||||
_advancedFrame = null;
|
_advancedFrame = null;
|
||||||
PlayerMovementController? controller = _host.Controller;
|
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;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!float.IsFinite(deltaSeconds) || deltaSeconds <= 0f)
|
if (!float.IsFinite(deltaSeconds) || deltaSeconds <= 0f)
|
||||||
{
|
{
|
||||||
|
|
@ -132,8 +142,18 @@ public sealed class RuntimeLocalPlayerFrameController
|
||||||
public void RunPostNetworkCommandPhase()
|
public void RunPostNetworkCommandPhase()
|
||||||
{
|
{
|
||||||
PlayerMovementController? controller = _host.Controller;
|
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;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
bool hidden = _host.IsHidden;
|
bool hidden = _host.IsHidden;
|
||||||
if (_advancedFrame is { } advanced
|
if (_advancedFrame is { } advanced
|
||||||
|
|
@ -163,8 +183,13 @@ public sealed class RuntimeLocalPlayerFrameController
|
||||||
{
|
{
|
||||||
frame = default;
|
frame = default;
|
||||||
PlayerMovementController? controller = _host.Controller;
|
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;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
bool hidden = _host.IsHidden;
|
bool hidden = _host.IsHidden;
|
||||||
if (_advancedFrame is { } advanced
|
if (_advancedFrame is { } advanced
|
||||||
|
|
|
||||||
|
|
@ -1541,6 +1541,276 @@ public sealed class HeadlessSessionHostTests
|
||||||
Assert.Equal(0, ownership.CollisionAdmissionCount);
|
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(
|
private static HeadlessSessionDescriptor Descriptor(
|
||||||
HeadlessCredentialProviderKind provider =
|
HeadlessCredentialProviderKind provider =
|
||||||
HeadlessCredentialProviderKind.Environment,
|
HeadlessCredentialProviderKind.Environment,
|
||||||
|
|
@ -2265,6 +2535,12 @@ public sealed class HeadlessSessionHostTests
|
||||||
public bool IsReady(uint fullCellId) =>
|
public bool IsReady(uint fullCellId) =>
|
||||||
fullCellId == LastCell;
|
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
|
// C3c-R1 review F7: the fixture window mirrors production's 3x3
|
||||||
// membership around the last requested center; no center yet means
|
// membership around the last requested center; no center yet means
|
||||||
// "within" (never convert before the first CenterOn).
|
// "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(
|
private sealed class FixtureEventRoute(
|
||||||
Action? onDispose = null) : ILiveSessionEventRouting
|
Action? onDispose = null) : ILiveSessionEventRouting
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue