diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 102fe6f4..f26f8f7f 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -5,10 +5,10 @@
# self-hosted runners carry the pinned SDK band from global.json already.
# actions/checkout IS mirrored, so it is used normally.
#
-# The suite runs through tools/run-release-gate.ps1 rather than a bare
-# `dotnet test`: that script owns the xUnit trait-lane filter which excludes
-# the InstalledDat / Live / Manual / OS-specific lanes. A bare `dotnet test`
-# fails ~36 tests by design because those lanes assert their own preconditions.
+# The streaming suite below mirrors tools/run-release-gate.ps1's portable
+# filter, including the capability-owned Vulkan lane. The ordinary runners
+# do not promise a Vulkan device. VulkanLaneOwnershipContractTests guards
+# filter parity; a bare dotnet test also selects environment-dependent lanes.
name: CI
on:
push:
@@ -52,7 +52,7 @@ jobs:
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
- $filter = 'Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=Linux&Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure'
+ $filter = 'Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=Linux&Lane!=Vulkan&Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure'
$failed = @()
foreach ($proj in Get-ChildItem tests -Directory | Sort-Object Name) {
$csproj = Join-Path $proj.FullName "$($proj.Name).csproj"
@@ -84,7 +84,7 @@ jobs:
echo '::group::AcDream.Core.Net.Tests (single-threaded)'
dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj \
-c Release --nologo \
- --filter 'Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure' \
+ --filter 'Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=Vulkan&Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure' \
-- xUnit.MaxParallelThreads=1
echo '::endgroup::'
@@ -98,7 +98,7 @@ jobs:
tests/AcDream.UI.Abstractions.Tests ; do
echo "::group::$p"
dotnet test "$p" -c Release --nologo \
- --filter 'Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure'
+ --filter 'Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=Vulkan&Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure'
echo "::endgroup::"
done
diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 1c0ea87d..7f375347 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -39,6 +39,20 @@ confirmed closed by the owner, 11 need a focused live gate, and 43 are safe to
remain closed. See
[`docs/research/2026-08-28-owner-closed-issue-validity-audit.md`](research/2026-08-28-owner-closed-issue-validity-audit.md).
+## #485 — Gitea portable CI races console capture and selects Vulkan-only tests
+
+**Status:** DONE — correction implemented and locally verified, 2026-09-06;
+new remote run to be verified after push. Run209 on3ebb120dd built cleanly,
+but Windows App tests failed: one StringBuilder capture race, four
+ErrorIncompatibleDriver failures. Linux passed; release was skipped.
+The camera diagnostics collection now excludes other parallel collections;
+Gitea filters match the canonical portable filter (including Lane!=Vulkan).
+Two regression guards added. Release test assembly build0W/0E and corrected
+App lane7,156/0/0 PASS; no production code changed. The dedicated Vulkan lane
+remains available and strict, not a runtime skip.
+Evidence and separate alternate-output build limitation:
+`research/2026-09-01-overhaul/2026-09-06-ci-209-correction.md`.
+
## #484 — Distant mountain silhouettes flicker and change with viewpoint
**Status:** OPEN — OWNER-DEFERRED, 2026-09-06, after report on #483 candidate.
diff --git a/docs/ci-and-releases.md b/docs/ci-and-releases.md
index 0155696f..acf16f0c 100644
--- a/docs/ci-and-releases.md
+++ b/docs/ci-and-releases.md
@@ -22,6 +22,14 @@ test can fail on them and a run costs ~7 minutes plus a 121 MB release. A red ga
cannot publish: `release` uses `needs:`, not a `workflow_run` trigger, whose
Forgejo support is unreliable.
+The streaming Windows filter must exactly match the default in
+`tools/run-release-gate.ps1`; Linux differs only by enabling `Lane=Linux`.
+`VulkanLaneOwnershipContractTests` enforces both copies. These ordinary Gitea
+runners do not promise Vulkan capability, so `Lane=Vulkan` is excluded here,
+not silently skipped inside a test. The explicit hardware lane remains in
+the GitHub `linux-vulkan` workflow and can be run locally on a capable device
+(see `release-gate.md`); a green Gitea run alone does not certify GPU pixels.
+
## Why Gitea and not GitHub
GitHub Actions is **billing-blocked** on this account ("recent account payments
diff --git a/docs/research/2026-09-01-overhaul/2026-09-06-ci-209-correction.md b/docs/research/2026-09-01-overhaul/2026-09-06-ci-209-correction.md
new file mode 100644
index 00000000..6980922e
--- /dev/null
+++ b/docs/research/2026-09-01-overhaul/2026-09-06-ci-209-correction.md
@@ -0,0 +1,65 @@
+# Post-closeout Gitea CI correction — #485, 2026-09-06
+
+## Evidence and cause
+
+[Run209](https://git.snakedesert.se/erik/acdream/actions/runs/209) tested
+`3ebb120dd271c873476cd071b2a33f09d17ce710`. Linux passed. Windows built
+successfully (0 warnings/errors), then App.Tests failed five cases:
+
+- `Collect_WeatherTurnFiredMatchesThePrintedOcLineExactly(true)`:
+ `StringBuilder.ToString` threw ArgumentOutOfRangeException (chunkLength).
+ The camera diagnostics collection serialized its members but still ran
+ beside other collections. Other tests could write to the process-wide
+ captured Console.Out while the test read its underlying StringWriter.
+ They could also observe the temporary global walk-transcript flag.
+- The four `Lane=Vulkan` pixel cases (ordinary shared-index, both
+ world-shader light strides, atmospheric receivers) failed at
+ `vkCreateInstance: ErrorIncompatibleDriver`. They were already correctly
+ tagged, but Gitea's copied portable filter lacked the Vulkan exclusion
+ present in `tools/run-release-gate.ps1`.
+
+Windows App result: 7,153 passed / 5 failed / 0 skipped. No other Windows
+test assembly failed. Release was skipped because it depends on both gates.
+These are test isolation and CI lane-routing defects, not a new distance
+algorithm failure.
+
+## Bounded correction
+
+Make the existing camera diagnostics collection exclusive with
+`DisableParallelization=true`. Other ordinary collections remain parallel.
+No timer, retry, assertion weakening or production change. A deterministic
+metadata regression protects exclusivity.
+
+Align the Windows Gitea filter with the canonical portable filter; both Linux
+filters retain only their intentional difference (enable Lane=Linux).
+A contract regression compares all three filters to that canonical source.
+The Vulkan tests are unchanged, still fail on missing capability when selected,
+and remain explicitly owned by the separate hardware lane. Gitea's ordinary
+runners do not certify GPU pixels; this distinction is documented.
+
+## Verification before push
+
+- Changed App test assembly Release build, reusing unchanged production
+ references (`BuildProjectReferences=false`): **PASS, 0 warnings/errors**.
+- Full App suite under the exact corrected Gitea Windows filter:
+ **7,156 passed / 0 failed / 0 skipped**. The total is run209's7,158 minus
+ four hardware-only cases plus two new regression tests, not lost coverage.
+ TRX: `artifacts/ci-209-fix-tests/ci-209-app-corrected.trx`.
+- Lead reviewed the console/global-isolation fix; independent review of
+ CI filters, ownership contract and documentation: **PASS**.
+- Running client binary SHA256 remained
+ `869BA14CB2C610E006C80BDFFCBD75629339A3486B8B98D7708B23050700FF34`.
+ Neither ACDream nor retail was closed or controlled.
+
+An attempted full solution build with alternate
+`--artifacts-path artifacts/ci-209-fix-build` failed in existing packaging
+path assumptions: smoke-plugin GetTargetPath resolves against App's absolute
+output path (MSB3030), and nested Bake publication misses its win-x64 assets
+target (NETSDK1047). That attempt is **FAIL**, not the passing test-only build
+above. No packaging workaround was added to this CI correction. The ordinary
+CI build already passed on the original commit; the subsequent remote run
+must verify the correction in CI's actual clean-build layout.
+
+This note records pre-push evidence. Remote success is reported only after
+observing the new commit's Gitea results. Campaign closeout and deferred #484
+are unchanged; no retail-behavior deviation is introduced.
diff --git a/tests/AcDream.App.Tests/Rendering/CameraDiagnosticsCollection.cs b/tests/AcDream.App.Tests/Rendering/CameraDiagnosticsCollection.cs
index d04df6e4..7ddc4f91 100644
--- a/tests/AcDream.App.Tests/Rendering/CameraDiagnosticsCollection.cs
+++ b/tests/AcDream.App.Tests/Rendering/CameraDiagnosticsCollection.cs
@@ -36,7 +36,9 @@ namespace AcDream.App.Tests.Rendering;
/// Console.SetOut by the transcript tests to capture output.
/// Interleaved redirection can restore a DISPOSED StringWriter as the
/// process-wide Console.Out, which then throws in unrelated
-/// tests.
+/// tests. Even classes that only write to the console can race a capture's
+/// StringWriter.ToString snapshot; Console's synchronized writer
+/// does not synchronize reads of the underlying StringBuilder.
///
///
///
@@ -54,13 +56,17 @@ namespace AcDream.App.Tests.Rendering;
/// per-test values (several are [Theory] cases that set different knobs
/// per case), so a fixture cannot own the save/restore without rewriting every
/// member's internals. The existing per-test finally blocks already
-/// return the defaults; this attribute supplies the mutual exclusion they were
-/// missing. Follows the WorldEnvironmentControllerCollection precedent.
+/// return the defaults. DisableParallelization additionally excludes
+/// OTHER collections for this collection's duration: member-only serialization
+/// cannot protect process-wide console captures or prevent unrelated walk tests
+/// from observing the temporary transcript flag. Ordinary collections can still
+/// run in parallel with each other. Follows the
+/// ThreadSchedulingCollection exclusivity precedent.
///
///
/// Evidence + history: docs/ISSUES.md #251.
///
-[CollectionDefinition(Name)]
+[CollectionDefinition(Name, DisableParallelization = true)]
public sealed class CameraDiagnosticsCollection
{
public const string Name = "Camera diagnostics globals";
diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanLaneOwnershipContractTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanLaneOwnershipContractTests.cs
index 90b9f860..d44365ee 100644
--- a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanLaneOwnershipContractTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanLaneOwnershipContractTests.cs
@@ -56,6 +56,32 @@ public sealed class VulkanLaneOwnershipContractTests
Assert.Contains("--filter \"Lane=Vulkan\"", hardwareStep, StringComparison.Ordinal);
}
+ [Fact]
+ public void GiteaPortableFilters_MatchCanonicalReleaseGateWithOnlyLinuxLaneEnabledOnLinux()
+ {
+ string root = RepositoryRoot();
+ string releaseGate = File.ReadAllText(Path.Combine(root, "tools", "run-release-gate.ps1"));
+ Match canonical = Regex.Match(
+ releaseGate,
+ "\\[string\\]\\$TestFilter\\s*=\\s*'(?[^']+)'",
+ RegexOptions.CultureInvariant);
+ Assert.True(canonical.Success, "Could not locate the canonical portable filter.");
+ string expected = canonical.Groups["filter"].Value;
+
+ string workflow = File.ReadAllText(Path.Combine(root, ".gitea", "workflows", "ci.yml"));
+ Match windows = Regex.Match(
+ workflow, "\\$filter\\s*=\\s*'(?[^']+)'", RegexOptions.CultureInvariant);
+ Assert.True(windows.Success, "Could not locate Gitea's Windows test filter.");
+ Assert.Equal(expected, windows.Groups["filter"].Value);
+
+ MatchCollection linux = Regex.Matches(
+ workflow, "--filter\\s+'(?[^']+)'", RegexOptions.CultureInvariant);
+ Assert.Equal(2, linux.Count);
+ string expectedLinux = string.Join('&', expected.Split('&').Where(term => term != "Lane!=Linux"));
+ foreach (Match filter in linux)
+ Assert.Equal(expectedLinux, filter.Groups["filter"].Value);
+ }
+
private static string Step(string workflow, int start)
{
int next = workflow.IndexOf(" - name:", start + 1, StringComparison.Ordinal);
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTranscriptTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTranscriptTests.cs
index 0f464860..a1f74f8c 100644
--- a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTranscriptTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTranscriptTests.cs
@@ -29,11 +29,25 @@ namespace AcDream.App.Tests.Rendering.Walk;
/// comment, issue #251): interleaved
/// Console.SetOut redirection across parallel test classes can
/// restore a disposed process-wide.
+/// The collection also excludes other parallel collections so their writes
+/// cannot race snapshots of the capture or contaminate the transcript.
///
///
[Collection(CameraDiagnosticsCollection.Name)]
public sealed partial class WalkFrameDriverTests
{
+ [Fact]
+ public void TranscriptCaptureCollection_ExcludesOtherParallelCollections()
+ {
+ // A shared collection alone only serializes its members. CI run 209
+ // raced unrelated console output against capture.ToString(), and other
+ // walk tests can also emit transcript lines while our global flag is on.
+ var definition = Assert.Single(typeof(CameraDiagnosticsCollection)
+ .GetCustomAttributes(typeof(CollectionDefinitionAttribute), inherit: false)
+ .Cast());
+ Assert.True(definition.DisableParallelization);
+ }
+
private static (WalkCell Cell1, WalkCell Cell2) BuildTranscriptFixtureCells(TestContext ctx)
{
var cell1 = new WalkCell