From 8be14d39928a66fc4a1c0d74f28684fb414a3c66 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 18 Aug 2026 17:50:50 +0200 Subject: [PATCH 001/263] ci: make GitHub workflows manual only --- .github/workflows/copilot-setup-steps.yml | 3 - .github/workflows/headless-portability.yml | 60 ------------------- .github/workflows/hygiene-assessment.lock.yml | 4 -- .github/workflows/release-gate.yml | 3 - 4 files changed, 70 deletions(-) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index f4841a25..d3d775c2 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -3,9 +3,6 @@ name: "Copilot Setup Steps" # This workflow configures the environment for GitHub Copilot Agent with gh-aw MCP server on: workflow_dispatch: - push: - paths: - - .github/workflows/copilot-setup-steps.yml jobs: # The job MUST be called 'copilot-setup-steps' to be recognized by GitHub Copilot Agent diff --git a/.github/workflows/headless-portability.yml b/.github/workflows/headless-portability.yml index 3586c816..2149e6b3 100644 --- a/.github/workflows/headless-portability.yml +++ b/.github/workflows/headless-portability.yml @@ -1,66 +1,6 @@ name: Headless portability on: - pull_request: - paths: - - ".github/workflows/headless-portability.yml" - - "AcDream.slnx" - - "src/AcDream.Platform/**" - - "src/AcDream.Launcher.Core/**" - - "src/AcDream.Launcher/**" - - "src/AcDream.Bake/**" - - "src/AcDream.Core/**" - - "src/AcDream.Core.Net/**" - - "src/AcDream.Content/**" - - "src/AcDream.Plugin.Abstractions/**" - - "src/AcDream.Runtime/**" - - "src/AcDream.Headless/**" - - "src/AcDream.App/**" - - "src/AcDream.UI.Abstractions/**" - - "tests/AcDream.Platform.Tests/**" - - "tests/AcDream.Launcher.Core.Tests/**" - - "tests/AcDream.Launcher.Tests/**" - - "tests/AcDream.Bake.Tests/**" - - "tests/AcDream.Core.Tests/**" - - "tests/AcDream.Core.Net.Tests/**" - - "tests/AcDream.Content.Tests/**" - - "tests/AcDream.Runtime.Tests/**" - - "tests/AcDream.Headless.Tests/**" - - "tests/AcDream.App.Tests/**" - - "tests/AcDream.UI.Abstractions.Tests/**" - - "tests/Fixtures/campaign-la/**" - - "tools/ShaderCompiler/**" - - "tools/compile-shaders.ps1" - push: - paths: - - ".github/workflows/headless-portability.yml" - - "AcDream.slnx" - - "src/AcDream.Platform/**" - - "src/AcDream.Launcher.Core/**" - - "src/AcDream.Launcher/**" - - "src/AcDream.Bake/**" - - "src/AcDream.Core/**" - - "src/AcDream.Core.Net/**" - - "src/AcDream.Content/**" - - "src/AcDream.Plugin.Abstractions/**" - - "src/AcDream.Runtime/**" - - "src/AcDream.Headless/**" - - "src/AcDream.App/**" - - "src/AcDream.UI.Abstractions/**" - - "tests/AcDream.Platform.Tests/**" - - "tests/AcDream.Launcher.Core.Tests/**" - - "tests/AcDream.Launcher.Tests/**" - - "tests/AcDream.Bake.Tests/**" - - "tests/AcDream.Core.Tests/**" - - "tests/AcDream.Core.Net.Tests/**" - - "tests/AcDream.Content.Tests/**" - - "tests/AcDream.Runtime.Tests/**" - - "tests/AcDream.Headless.Tests/**" - - "tests/AcDream.App.Tests/**" - - "tests/AcDream.UI.Abstractions.Tests/**" - - "tests/Fixtures/campaign-la/**" - - "tools/ShaderCompiler/**" - - "tools/compile-shaders.ps1" workflow_dispatch: permissions: diff --git a/.github/workflows/hygiene-assessment.lock.yml b/.github/workflows/hygiene-assessment.lock.yml index a7294727..3c5ebe78 100644 --- a/.github/workflows/hygiene-assessment.lock.yml +++ b/.github/workflows/hygiene-assessment.lock.yml @@ -49,9 +49,6 @@ name: "acdream Hygiene Assessment" on: - schedule: - - cron: "54 4 * * *" - # Friendly format: daily (scattered) workflow_dispatch: {} permissions: {} @@ -1348,4 +1345,3 @@ jobs: /tmp/gh-aw/safe-output-items.jsonl /tmp/gh-aw/temporary-id-map.json if-no-files-found: ignore - diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml index 949e26d2..b7240505 100644 --- a/.github/workflows/release-gate.yml +++ b/.github/workflows/release-gate.yml @@ -1,9 +1,6 @@ name: Complete Release gate on: - pull_request: - push: - branches: [main] workflow_dispatch: permissions: From 600c331ac6801cb9c18b7802231f0d9aecdb959d Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 18 Aug 2026 19:49:06 +0200 Subject: [PATCH 002/263] feat(launcher): Gitea-backed alpha update feed replaces the GitHub Releases source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The launcher reported "no client available" because its update source was pinned to a GitHub Releases manifest in a PRIVATE repo — nothing anonymous could ever be fetched from it. Switch the feed to the PUBLIC Gitea repo so a friend needs no account, and add the two commands that publish it. - ReleaseManifestClient.ProductionManifestUri now points at git.snakedesert.se/erik/acdream raw on the `dist` branch. No update machinery changed: the existing strict reader already accepts any HTTPS manifest, so this is a URL swap plus a build script. - tools/publish-bin.ps1 publishes the payloads into /bin and writes bin/manifest.json (schema v1, SHA-256 + size per artifact): client-win-x64.zip AcDream.App + acdream-headless launcher-win-x64.zip acdream-launcher + co-deployed acdream-bake Stamps InformationalVersion ONLY — never -p:Version, which also rewrites project-reference versions inside the committed packages..lock.json files and churned every one of them with a throwaway build stamp. - tools/publish-dist.ps1 pushes /bin to the Gitea-only `dist` branch from a throwaway worktree, leaving the developer's checkout, index, and HEAD untouched. It refuses a GitHub remote outright. Why `dist` and not main: the launcher payload is ~103 MB because the launcher and its co-deployed bake CLI are each self-contained single files (deliberate, see AcDream.Launcher.csproj). GitHub hard-rejects files over 100 MB, and all three refs currently track main, so payloads on main would break every GitHub push. `dist` is a single-commit orphan branch that each publish REPLACES, so superseded builds never accumulate. /bin stays gitignored repo-wide and is force-added only on that branch. Verified live: manifest and both payloads serve anonymously over HTTPS, and a downloaded client payload matches its declared SHA-256 and size byte for byte. Co-Authored-By: Claude Fable 5 --- .gitignore | 5 + .../Updates/ReleaseManifestClient.cs | 13 +- .../Updates/ReleaseTransportTests.cs | 11 +- tools/publish-bin.ps1 | 226 ++++++++++++++++++ tools/publish-dist.ps1 | 161 +++++++++++++ 5 files changed, 410 insertions(+), 6 deletions(-) create mode 100644 tools/publish-bin.ps1 create mode 100644 tools/publish-dist.ps1 diff --git a/.gitignore b/.gitignore index 894a5f03..045c317a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,11 @@ bin/ obj/ out/ +# NOTE: the repo-root /bin folder holds the alpha distribution feed written by +# tools/publish-bin.ps1. It stays IGNORED here on purpose so a stray `git add` +# can never put ~150 MB of payloads on main (GitHub also hard-rejects any file +# over 100 MB). tools/publish-dist.ps1 force-adds it onto the Gitea-only `dist` +# branch instead, which is what the launcher's update feed reads. # Rider / VS .idea/ diff --git a/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs b/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs index 7736fee5..a62d9004 100644 --- a/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs +++ b/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs @@ -19,14 +19,21 @@ public interface IReleaseManifestClient /// public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable { - public const string GitHubOwner = "eriknihlen"; - public const string GitHubRepository = "acdream"; public const int MaximumManifestBytes = 1024 * 1024; public const long MaximumArtifactBytes = 4L * 1024 * 1024 * 1024; public const int MaximumRedirects = 5; + /// + /// Alpha distribution feed: the public Gitea repository's dist + /// branch, served as raw files (anonymous raw reads verified 2026-08-18, + /// so a friend needs no account). GitHub stays private, and its 100 MB + /// per-file limit could not carry the launcher payload anyway, so the + /// payloads live on dist — pushed to Gitea only — instead of on + /// main. tools/publish-bin.ps1 builds them and + /// tools/publish-dist.ps1 publishes that branch. + /// public static Uri ProductionManifestUri { get; } = new( - $"https://github.com/{GitHubOwner}/{GitHubRepository}/releases/latest/download/manifest.json"); + "https://git.snakedesert.se/erik/acdream/raw/branch/dist/bin/manifest.json"); private static readonly JsonSerializerOptions SerializerOptions = new() { diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs index 0208d60d..b43c7aa3 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs @@ -91,11 +91,16 @@ public sealed class ReleaseManifestClientTests Assert.Equal("2.1.0", manifest.Version.Value); Assert.Equal(client.LongLength, manifest.RequireClient("win-x64").Size); - Assert.Equal("eriknihlen", ReleaseManifestClient.GitHubOwner); - Assert.Equal("acdream", ReleaseManifestClient.GitHubRepository); + // Alpha distribution feed: the PUBLIC Gitea repo's `dist` branch + // (GitHub stays private, so its Releases feed cannot serve anonymous + // friend installs, and its 100 MB per-file limit could not carry the + // launcher payload). tools/publish-bin.ps1 writes the payloads and + // tools/publish-dist.ps1 pushes that branch — all three must agree on + // this URL. Assert.Equal( - "https://github.com/eriknihlen/acdream/releases/latest/download/manifest.json", + "https://git.snakedesert.se/erik/acdream/raw/branch/dist/bin/manifest.json", ReleaseManifestClient.ProductionManifestUri.AbsoluteUri); + Assert.Equal(Uri.UriSchemeHttps, ReleaseManifestClient.ProductionManifestUri.Scheme); } [Theory] diff --git a/tools/publish-bin.ps1 b/tools/publish-bin.ps1 new file mode 100644 index 00000000..6348826e --- /dev/null +++ b/tools/publish-bin.ps1 @@ -0,0 +1,226 @@ +<# +.SYNOPSIS + Publishes the alpha distribution feed into the repo's /bin folder. + +.DESCRIPTION + Builds self-contained client and launcher payloads, zips them, and writes + bin/manifest.json pointing at this repository's PUBLIC Gitea raw URLs. + Commit and push /bin afterwards and the launcher's "Check for updates" + finds the new build — no release server, no tokens, no CDN. + + Payload contents (what the launcher expects to find at each zip root): + client-.zip AcDream.App[.exe] + acdream-headless[.exe] + launcher-.zip acdream-launcher[.exe] + acdream-bake[.exe] + (the launcher csproj co-deploys the bake CLI) + + The launcher's own published version is stamped to the manifest version so + a freshly published launcher does NOT report itself as out of date. + +.PARAMETER Version + SemVer 2.0 release version. Defaults to a monotonic UTC build stamp, + e.g. 0.1.0-build.202608181552. Must sort ABOVE the previous published + version or the launcher will not offer it as an update. + +.PARAMETER IncludeLinux + Also publish linux-x64 payloads. Off by default: Linux graphical is parked + at Slice L1, and each RID roughly doubles build time and feed size. + +.EXAMPLE + pwsh -NoProfile -File tools/publish-bin.ps1 + git add bin + git commit -m "release: alpha build" + git push origin main +#> +[CmdletBinding()] +param( + [string]$Version, + [switch]$IncludeLinux, + [string]$MinimumLauncherVersion = '0.0.1' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'publish-bin requires PowerShell 7 or newer.' +} + +$RepoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +if (-not (Test-Path (Join-Path $RepoRoot 'AcDream.slnx'))) { + throw "Could not locate AcDream.slnx above '$PSScriptRoot'." +} + +if ([string]::IsNullOrWhiteSpace($Version)) { + $Version = '0.1.0-build.{0}' -f ([DateTime]::UtcNow.ToString('yyyyMMddHHmm')) +} + +$semver = '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$' +foreach ($candidate in @($Version, $MinimumLauncherVersion)) { + if ($candidate -notmatch $semver) { + throw "Version '$candidate' is not SemVer 2.0 (build metadata '+' is not allowed here)." + } +} + +# Raw-file base for the PUBLIC Gitea repo's dist branch. Must agree with +# ReleaseManifestClient.ProductionManifestUri. +$RawBase = 'https://git.snakedesert.se/erik/acdream/raw/branch/dist/bin' + +$BinRoot = Join-Path $RepoRoot 'bin' +$Staging = Join-Path $BinRoot 'payload' +$Rids = @('win-x64') +if ($IncludeLinux) { $Rids += 'linux-x64' } + +Write-Host "acdream alpha feed" -ForegroundColor Cyan +Write-Host " version : $Version" +Write-Host " rids : $($Rids -join ', ')" +Write-Host " output : $BinRoot" +Write-Host '' + +if (Test-Path $Staging) { Remove-Item -LiteralPath $Staging -Recurse -Force } +$null = New-Item -ItemType Directory -Path $Staging -Force + +function Invoke-Publish { + param( + [Parameter(Mandatory)][string]$Project, + [Parameter(Mandatory)][string]$Rid, + [Parameter(Mandatory)][string]$OutputDirectory, + [switch]$SingleFile + ) + + $arguments = @( + 'publish', (Join-Path $RepoRoot $Project), + '-c', 'Release', + '-r', $Rid, + '--self-contained', 'true', + # InformationalVersion ONLY — never -p:Version. The launcher reads + # AssemblyInformationalVersion (App.GetLauncherVersion), while Version + # also rewrites project-reference versions inside the committed + # packages..lock.json files, so stamping it churned every lock + # file with a throwaway build stamp (observed 2026-08-18). + "-p:InformationalVersion=$Version", + # No SourceLink '+' suffix: LauncherVersion parses this as SemVer. + '-p:IncludeSourceRevisionInInformationalVersion=false', + '-o', $OutputDirectory, + '--nologo' + ) + if ($SingleFile) { $arguments += '-p:PublishSingleFile=true' } + + & dotnet @arguments | Out-Null + if ($LASTEXITCODE) { throw "publish failed: $Project ($Rid)" } +} + +function New-PayloadZip { + param( + [Parameter(Mandatory)][string]$SourceDirectory, + [Parameter(Mandatory)][string]$ZipPath, + [Parameter(Mandatory)][string[]]$RequiredFiles + ) + + foreach ($required in $RequiredFiles) { + if (-not (Test-Path -LiteralPath (Join-Path $SourceDirectory $required))) { + throw "Payload '$SourceDirectory' is missing required file '$required'." + } + } + + if (Test-Path -LiteralPath $ZipPath) { Remove-Item -LiteralPath $ZipPath -Force } + # includeBaseDirectory:$false -> entries sit at the zip ROOT, which is where + # LauncherExecutableSet resolves the hosts after extraction. + [IO.Compression.ZipFile]::CreateFromDirectory( + $SourceDirectory, + $ZipPath, + [IO.Compression.CompressionLevel]::Optimal, + $false) +} + +function Get-Artifact { + param( + [Parameter(Mandatory)][string]$ZipPath, + [Parameter(Mandatory)][string]$Url + ) + + $item = Get-Item -LiteralPath $ZipPath + return [ordered]@{ + url = $Url + sha256 = (Get-FileHash -LiteralPath $ZipPath -Algorithm SHA256).Hash.ToLowerInvariant() + size = $item.Length + } +} + +Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue + +$clients = [ordered]@{} +$launchers = [ordered]@{} + +foreach ($rid in $Rids) { + $suffix = if ($rid -like 'win-*') { '.exe' } else { '' } + $clientDirectory = Join-Path $Staging "client-$rid" + $launcherDirectory = Join-Path $Staging "launcher-$rid" + + Write-Host "[$rid] publishing client (App + Headless)..." -ForegroundColor Yellow + Invoke-Publish 'src/AcDream.App/AcDream.App.csproj' $rid $clientDirectory + Invoke-Publish 'src/AcDream.Headless/AcDream.Headless.csproj' $rid $clientDirectory + + # PublishSingleFile/SelfContained come from the launcher csproj itself, and + # its PublishCoDeployedBakeTool target adds acdream-bake as a SECOND + # self-contained single file in the same directory. Two independent .NET + # runtimes is deliberate (see that csproj's comments: a framework-dependent + # bake would scatter AcDream.Content assemblies into the launcher's output), + # so this payload is ~103 MB and CANNOT be pushed to GitHub, whose hard + # per-file limit is 100 MB. The alpha feed is Gitea-only by design. + Write-Host "[$rid] publishing launcher (+ co-deployed bake)..." -ForegroundColor Yellow + Invoke-Publish 'src/AcDream.Launcher/AcDream.Launcher.csproj' $rid $launcherDirectory + + $clientZip = Join-Path $BinRoot "client-$rid.zip" + $launcherZip = Join-Path $BinRoot "launcher-$rid.zip" + + Write-Host "[$rid] packing..." -ForegroundColor Yellow + New-PayloadZip $clientDirectory $clientZip @("AcDream.App$suffix", "acdream-headless$suffix") + New-PayloadZip $launcherDirectory $launcherZip @("acdream-launcher$suffix", "acdream-bake$suffix") + + $clients[$rid] = Get-Artifact $clientZip "$RawBase/client-$rid.zip" + $launchers[$rid] = Get-Artifact $launcherZip "$RawBase/launcher-$rid.zip" +} + +$manifest = [ordered]@{ + schemaVersion = 1 + version = $Version + minimumLauncherVersion = $MinimumLauncherVersion + clients = $clients + launchers = $launchers +} + +$manifestPath = Join-Path $BinRoot 'manifest.json' +$json = $manifest | ConvertTo-Json -Depth 6 +[IO.File]::WriteAllText($manifestPath, $json + "`n", [Text.UTF8Encoding]::new($false)) + +Remove-Item -LiteralPath $Staging -Recurse -Force + +Write-Host '' +Write-Host 'Feed written:' -ForegroundColor Green +$total = 0L +foreach ($file in (Get-ChildItem -LiteralPath $BinRoot -File | Sort-Object Name)) { + $total += $file.Length + ' {0,-26} {1,10:N1} MB' -f $file.Name, ($file.Length / 1MB) | Write-Host +} +' {0,-26} {1,10:N1} MB' -f 'TOTAL', ($total / 1MB) | Write-Host +# A RID-specific restore rewrites packages..lock.json for the projects it +# touches, so a feed build can leave the working tree dirty even though nothing +# about the source changed. Report it rather than silently reverting: the files +# are the developer's, and a real dependency change must not be swallowed here. +$dirtyLocks = @( + & git -C $RepoRoot status --porcelain -- '*packages.*.lock.json' 2>$null +) | Where-Object { $_ } +if ($dirtyLocks.Count -gt 0) { + Write-Host '' + Write-Host 'Note: the RID restore modified these lock files:' -ForegroundColor Yellow + $dirtyLocks | ForEach-Object { " $($_.Trim())" | Write-Host } + Write-Host ' If you did not change dependencies, discard them:' -ForegroundColor DarkGray + Write-Host " git checkout -- '*packages.*.lock.json'" -ForegroundColor DarkGray +} + +Write-Host '' +Write-Host 'Next — publish the feed to Gitea:' -ForegroundColor Cyan +Write-Host " pwsh -NoProfile -File tools/publish-dist.ps1" +Write-Host '' +Write-Host ' (bin/ is gitignored on purpose: publish-dist puts it on the' -ForegroundColor DarkGray +Write-Host ' Gitea-only dist branch, never on main / GitHub.)' -ForegroundColor DarkGray diff --git a/tools/publish-dist.ps1 b/tools/publish-dist.ps1 new file mode 100644 index 00000000..7628b863 --- /dev/null +++ b/tools/publish-dist.ps1 @@ -0,0 +1,161 @@ +<# +.SYNOPSIS + Publishes the /bin alpha feed to the Gitea-only `dist` branch. + +.DESCRIPTION + Copies the payloads written by tools/publish-bin.ps1 onto an orphan `dist` + branch and pushes it to Gitea (the `origin` remote). The launcher's update + check reads that branch's raw URLs. + + Why a separate branch, not main: + * The launcher payload is ~103 MB. GitHub hard-rejects any file over + 100 MB, so payloads on main would break every GitHub push. + * Each build is ~150 MB. On main that weight would land in the history + every developer clones forever. + `dist` is a single-commit ORPHAN branch — each publish REPLACES it, so the + feed never accumulates old builds. Nothing on it is a parent of main. + +.PARAMETER Remote + Remote to publish to. Defaults to `origin` (Gitea). Never pass the GitHub + remote: the payload exceeds its per-file limit. + +.EXAMPLE + pwsh -NoProfile -File tools/publish-bin.ps1 + pwsh -NoProfile -File tools/publish-dist.ps1 +#> +[CmdletBinding()] +param( + [string]$Remote = 'origin', + [switch]$KeepHistory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'publish-dist requires PowerShell 7 or newer.' +} + +$RepoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$BinRoot = Join-Path $RepoRoot 'bin' +$ManifestPath = Join-Path $BinRoot 'manifest.json' + +if (-not (Test-Path -LiteralPath $ManifestPath)) { + throw "No feed found at '$ManifestPath'. Run tools/publish-bin.ps1 first." +} + +$manifest = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json +$version = $manifest.version +if ([string]::IsNullOrWhiteSpace($version)) { + throw 'The manifest has no version.' +} + +$payloads = @(Get-ChildItem -LiteralPath $BinRoot -File -Filter *.zip) +if ($payloads.Count -eq 0) { + throw "No .zip payloads in '$BinRoot'. Run tools/publish-bin.ps1 first." +} + +$remoteUrl = (& git -C $RepoRoot remote get-url $Remote 2>&1) +if ($LASTEXITCODE) { throw "Remote '$Remote' is not configured." } +if ($remoteUrl -match 'github\.com') { + throw "Refusing to publish payloads to '$Remote' ($remoteUrl): GitHub " + + 'rejects files over 100 MB and the alpha feed is Gitea-only.' +} + +Write-Host "Publishing alpha feed $version to $Remote ($remoteUrl)" -ForegroundColor Cyan +foreach ($payload in $payloads) { + ' {0,-26} {1,8:N1} MB' -f $payload.Name, ($payload.Length / 1MB) | Write-Host +} + +# A throwaway worktree keeps the developer's checkout, index, and HEAD +# completely untouched while the dist branch is built and pushed. +$stamp = [DateTime]::UtcNow.ToString('yyyyMMddHHmmss') +$workTree = Join-Path ([IO.Path]::GetTempPath()) "acdream-dist-$stamp" +$branch = 'dist' +# Build under a unique local branch and push it AS dist. Reusing the name +# locally breaks the second publish outright: `checkout --orphan dist` fails +# once a local dist ref exists (observed 2026-08-18). +$stagingBranch = "dist-publish-$stamp" + +try { + if ($KeepHistory) { + & git -C $RepoRoot fetch $Remote $branch 2>&1 | Out-Null + $hasRemoteBranch = -not $LASTEXITCODE + & git -C $RepoRoot worktree add --no-checkout -b $stagingBranch $workTree ` + $(if ($hasRemoteBranch) { "$Remote/$branch" } else { 'HEAD' }) 2>&1 | Out-Null + if ($LASTEXITCODE) { throw 'Could not create the dist worktree.' } + & git -C $workTree checkout . 2>&1 | Out-Null + } + else { + # Default: one commit, no ancestry. Each publish REPLACES the branch so + # superseded payloads never pile up in the object store. + & git -C $RepoRoot worktree add --detach $workTree 2>&1 | Out-Null + if ($LASTEXITCODE) { throw 'Could not create the dist worktree.' } + & git -C $workTree checkout --orphan $stagingBranch 2>&1 | Out-Null + if ($LASTEXITCODE) { throw 'Could not start the dist branch.' } + & git -C $workTree rm -rf --cached . 2>&1 | Out-Null + Get-ChildItem -LiteralPath $workTree -Force | + Where-Object { $_.Name -ne '.git' } | + Remove-Item -Recurse -Force + } + + $targetBin = Join-Path $workTree 'bin' + if (Test-Path -LiteralPath $targetBin) { + Remove-Item -LiteralPath $targetBin -Recurse -Force + } + $null = New-Item -ItemType Directory -Path $targetBin -Force + Copy-Item -LiteralPath $ManifestPath -Destination $targetBin + foreach ($payload in $payloads) { + Copy-Item -LiteralPath $payload.FullName -Destination $targetBin + } + + $readme = @" +# acdream alpha feed + +Published by ``tools/publish-dist.ps1``. This branch carries ONLY the launcher +update feed — it has no source history and is never merged into ``main``. + +Current release: **$version** + +## For players + +1. Download ``bin/launcher-win-x64.zip``. +2. Unzip it anywhere and run ``acdream-launcher.exe``. +3. The launcher installs the game client and keeps both up to date. + +You need Asheron's Call's DAT files for first-run setup. +"@ + [IO.File]::WriteAllText( + (Join-Path $workTree 'README.md'), + $readme, + [Text.UTF8Encoding]::new($false)) + + # bin/ is gitignored repo-wide (so main can never take the payloads by + # accident) — force-add it here, where it is the whole point of the branch. + & git -C $workTree add -f bin README.md + if ($LASTEXITCODE) { throw 'Could not stage the feed.' } + + & git -C $workTree commit -q -m "release: acdream alpha $version" 2>&1 | Out-Null + if ($LASTEXITCODE) { + Write-Host 'Nothing changed since the last publish.' -ForegroundColor Yellow + } + + & git -C $workTree push --force $Remote "${stagingBranch}:${branch}" + if ($LASTEXITCODE) { throw "Push to $Remote/$branch failed." } +} +finally { + if (Test-Path -LiteralPath $workTree) { + & git -C $RepoRoot worktree remove --force $workTree 2>&1 | Out-Null + if (Test-Path -LiteralPath $workTree) { + Remove-Item -LiteralPath $workTree -Recurse -Force -ErrorAction SilentlyContinue + } + } + & git -C $RepoRoot worktree prune 2>&1 | Out-Null + # The staging branch exists only to carry one publish to the remote. + & git -C $RepoRoot branch -D $stagingBranch 2>&1 | Out-Null +} + +Write-Host '' +Write-Host "Published $version." -ForegroundColor Green +Write-Host 'Feed: https://git.snakedesert.se/erik/acdream/raw/branch/dist/bin/manifest.json' +Write-Host 'Players: https://git.snakedesert.se/erik/acdream/src/branch/dist' From f260260cafb36d6825775b47d7b1c19434ad68e4 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 18 Aug 2026 19:56:20 +0200 Subject: [PATCH 003/263] fix(launcher-feed): strip debug symbols from distribution payloads (103 MB -> 77 MB) A stock publish shipped native debug symbols to players: libSkiaSharp.pdb (80 MB) and libHarfBuzzSharp.pdb (20 MB) from Avalonia's rendering packages were 100 MB of a 278 MB launcher payload. MSBuild's DebugType switches only govern our own managed symbols, not native .pdb files arriving as package runtime assets, so the payload build drops every .pdb before zipping. launcher-win-x64.zip 103.4 -> 77.4 MB, client 44.5 -> 43.6 MB. The launcher payload now also fits under GitHub's 100 MB per-file limit, though the feed stays on the Gitea-only dist branch to keep main's history clean. Also fixes a StrictMode crash in the lock-file warning: an empty git status result is null, not an empty array, so .Count threw at the end of a successful publish. Co-Authored-By: Claude Fable 5 --- tools/publish-bin.ps1 | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/tools/publish-bin.ps1 b/tools/publish-bin.ps1 index 6348826e..d1df1889 100644 --- a/tools/publish-bin.ps1 +++ b/tools/publish-bin.ps1 @@ -109,6 +109,23 @@ function Invoke-Publish { if ($LASTEXITCODE) { throw "publish failed: $Project ($Rid)" } } +function Remove-DebugSymbols { + param([Parameter(Mandatory)][string]$Directory) + + # Players never load these, and the native ones are enormous: a stock + # Avalonia publish ships libSkiaSharp.pdb (80 MB) and + # libHarfBuzzSharp.pdb (20 MB), which alone were 100 MB of a 278 MB + # launcher payload (measured 2026-08-18). MSBuild's DebugType switches + # only govern OUR managed symbols, not the native .pdb files that arrive + # as package runtime assets, so drop them from the payload directly. + $symbols = @(Get-ChildItem -LiteralPath $Directory -Recurse -File -Filter *.pdb) + if ($symbols.Count -eq 0) { return } + $freed = ($symbols | Measure-Object -Property Length -Sum).Sum + $symbols | Remove-Item -Force + ' stripped {0} debug symbol file(s), {1:N1} MB' -f $symbols.Count, ($freed / 1MB) | + Write-Host -ForegroundColor DarkGray +} + function New-PayloadZip { param( [Parameter(Mandatory)][string]$SourceDirectory, @@ -116,6 +133,8 @@ function New-PayloadZip { [Parameter(Mandatory)][string[]]$RequiredFiles ) + Remove-DebugSymbols $SourceDirectory + foreach ($required in $RequiredFiles) { if (-not (Test-Path -LiteralPath (Join-Path $SourceDirectory $required))) { throw "Payload '$SourceDirectory' is missing required file '$required'." @@ -208,8 +227,9 @@ foreach ($file in (Get-ChildItem -LiteralPath $BinRoot -File | Sort-Object Name) # about the source changed. Report it rather than silently reverting: the files # are the developer's, and a real dependency change must not be swallowed here. $dirtyLocks = @( - & git -C $RepoRoot status --porcelain -- '*packages.*.lock.json' 2>$null -) | Where-Object { $_ } + @(& git -C $RepoRoot status --porcelain -- '*packages.*.lock.json' 2>$null) | + Where-Object { $_ } +) if ($dirtyLocks.Count -gt 0) { Write-Host '' Write-Host 'Note: the RID restore modified these lock files:' -ForegroundColor Yellow From ba20d4bbf4968a60eb5368b92c7bed5bd3637fdd Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 10:11:26 +0200 Subject: [PATCH 004/263] ci: add Gitea Actions smoke workflow for the self-hosted Windows runner --- .gitea/workflows/smoke.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .gitea/workflows/smoke.yml diff --git a/.gitea/workflows/smoke.yml b/.gitea/workflows/smoke.yml new file mode 100644 index 00000000..f161eaf8 --- /dev/null +++ b/.gitea/workflows/smoke.yml @@ -0,0 +1,21 @@ +# Gitea Actions smoke test for the self-hosted Windows runner (RARE-win). +# Deliberately uses NO `uses:` steps: Gitea resolves actions from +# data.forgejo.org, which does not carry actions/checkout@v6 or +# actions/setup-dotnet@v4, so the .github workflows cannot run here as-is. +# This proves runner execution independently of that. +name: smoke +on: + workflow_dispatch: + +jobs: + windows-smoke: + runs-on: windows-latest + steps: + - name: Report the build environment + shell: pwsh + run: | + Write-Host "host : $env:COMPUTERNAME" + Write-Host "user : $env:USERNAME" + Write-Host "cores : $env:NUMBER_OF_PROCESSORS" + dotnet --list-sdks + git --version From 5256a39fb3c670bba33cb5e865dc27adcb830b0a Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 10:20:27 +0200 Subject: [PATCH 005/263] ci: extend smoke workflow with a real Linux build job --- .gitea/workflows/smoke.yml | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/.gitea/workflows/smoke.yml b/.gitea/workflows/smoke.yml index f161eaf8..52ea8d74 100644 --- a/.gitea/workflows/smoke.yml +++ b/.gitea/workflows/smoke.yml @@ -1,8 +1,8 @@ -# Gitea Actions smoke test for the self-hosted Windows runner (RARE-win). +# Gitea Actions smoke test for the self-hosted runners. # Deliberately uses NO `uses:` steps: Gitea resolves actions from # data.forgejo.org, which does not carry actions/checkout@v6 or # actions/setup-dotnet@v4, so the .github workflows cannot run here as-is. -# This proves runner execution independently of that. +# Checkout is therefore done with plain git against the same instance. name: smoke on: workflow_dispatch: @@ -11,11 +11,25 @@ jobs: windows-smoke: runs-on: windows-latest steps: - - name: Report the build environment + - name: Environment shell: pwsh run: | - Write-Host "host : $env:COMPUTERNAME" - Write-Host "user : $env:USERNAME" - Write-Host "cores : $env:NUMBER_OF_PROCESSORS" + Write-Host "host=$env:COMPUTERNAME user=$env:USERNAME cores=$env:NUMBER_OF_PROCESSORS" dotnet --list-sdks git --version + + linux-build: + runs-on: ubuntu-latest + steps: + - name: Environment + run: | + echo "host=$(hostname) user=$(whoami) cores=$(nproc)" + dotnet --list-sdks + git --version + - name: Clone and build a real project + run: | + set -e + rm -rf src-smoke + git clone --depth 1 https://git.snakedesert.se/erik/acdream.git src-smoke + cd src-smoke + dotnet build src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj -c Release --nologo From b746d3d61b6963ec6172123fbb4e1a0f68d424ad Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 10:35:36 +0200 Subject: [PATCH 006/263] =?UTF-8?q?ci:=20Gitea=20pipeline=20=E2=80=94=20ga?= =?UTF-8?q?te=20on=20both=20self-hosted=20runners,=20publish=20alpha=20rel?= =?UTF-8?q?eases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every push to main now runs the gate on the self-hosted runners and, when green, publishes a Gitea Release carrying the client, launcher+bake, and manifest. Pipeline (.gitea/workflows/ci.yml): - windows-gate runs tools/run-release-gate.ps1, the project's own bounded gate. A bare `dotnet test AcDream.slnx` is NOT usable as a gate: it fails ~36 tests by design, because the InstalledDat/Live/Manual/OS lanes assert their own preconditions. The gate script's trait filter is what excludes them. - linux-portable runs the portable closure, where the Linux-lane tests actually execute instead of failing on Windows. - release depends on both, so a red gate cannot publish. It is a job in the same workflow rather than a workflow_run trigger, whose Forgejo support is unreliable; `needs` is guaranteed. No actions/setup-dotnet: data.forgejo.org does not mirror it at all (404), and both runners carry the pinned SDK band already. actions/checkout IS mirrored and is used normally. Release payloads become release ATTACHMENTS, outside git history, so ~120 MB per build never enters a branch. Only the ~500-byte manifest.json is committed, to the payload-free dist branch, because Forgejo has no /releases/latest/download/ route (verified 404) for the launcher to poll. publish-bin.ps1 takes -BaseUrl so the manifest points at the release tag. Two real gate failures fixed: - LauncherProjectBoundaryTests asserted four `**` path filters belonging to the push triggers that 8be14d39 removed when workflows went manual-only. The assertions about what the workflow DOES are untouched. - MainWindowViewTests failed in Test Case Cleanup with "calling thread cannot access this object" while passing in isolation: Avalonia's headless session is thread-affine and xUnit ran collections in parallel. Serialized via xunit.runner.json, the same settings AcDream.Core.Tests already uses. Local gate: 12 projects, 14,346 tests, 0 failures. Co-Authored-By: Claude Fable 5 --- .gitea/workflows/ci.yml | 140 ++++++++++++++++++ .../AcDream.Launcher.Tests.csproj | 10 ++ .../LauncherProjectBoundaryTests.cs | 11 +- .../AcDream.Launcher.Tests/xunit.runner.json | 6 + tools/publish-bin.ps1 | 14 +- 5 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 tests/AcDream.Launcher.Tests/xunit.runner.json diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 00000000..5c203078 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,140 @@ +# Gitea Actions CI gate for the self-hosted runners. +# +# Deliberately does NOT use actions/setup-dotnet: data.forgejo.org (the mirror +# Gitea resolves actions from) does not host that action at all, and the +# 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. +name: CI +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + windows-gate: + runs-on: windows-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v6 + + - name: Verify the pinned SDK band resolves + shell: pwsh + run: | + dotnet --version + dotnet --list-sdks + + - name: Complete Release gate + shell: pwsh + run: ./tools/run-release-gate.ps1 + + - name: Upload gate evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: release-gate-windows + path: artifacts/release-gate/ + if-no-files-found: warn + retention-days: 14 + + linux-portable: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v6 + + - name: Portable closure (Linux lanes run here, not on Windows) + run: | + set -e + dotnet --version + for p in \ + tests/AcDream.Platform.Tests \ + tests/AcDream.Core.Tests \ + tests/AcDream.Core.Net.Tests \ + tests/AcDream.Content.Tests \ + tests/AcDream.Runtime.Tests \ + tests/AcDream.Headless.Tests \ + tests/AcDream.Launcher.Core.Tests \ + 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!=Windows&Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure' + echo "::endgroup::" + done + + release: + # Same workflow rather than a workflow_run trigger: workflow_run is a + # GitHub feature whose Forgejo support is unreliable, while `needs` is + # guaranteed. A red gate therefore cannot publish. + needs: [windows-gate, linux-portable] + runs-on: windows-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v6 + + - name: Compute release version + id: ver + shell: pwsh + run: | + $v = '0.1.0-build.{0}' -f ([DateTime]::UtcNow.ToString('yyyyMMddHHmm')) + "version=$v" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + Write-Host "release version: $v" + + - name: Build payloads with release-attachment URLs + shell: pwsh + env: + TAG: ${{ steps.ver.outputs.version }} + run: | + ./tools/publish-bin.ps1 -Version $env:TAG -BaseUrl "${{ github.server_url }}/${{ github.repository }}/releases/download/$env:TAG" + + - name: Create the release and upload payloads + shell: pwsh + env: + TAG: ${{ steps.ver.outputs.version }} + TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + $ErrorActionPreference = 'Stop' + $api = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}" + $headers = @{ Authorization = "token $env:TOKEN" } + $body = @{ + tag_name = $env:TAG + name = "acdream alpha $env:TAG" + body = "Automated alpha build from ${{ github.sha }}." + draft = $false + prerelease = $true + target_commitish = 'main' + } | ConvertTo-Json + $release = Invoke-RestMethod -Method Post -Uri "$api/releases" -Headers $headers -ContentType 'application/json' -Body $body + Write-Host "created release id=$($release.id)" + foreach ($f in Get-ChildItem bin -File) { + Write-Host ("uploading {0} ({1:N1} MB)" -f $f.Name, ($f.Length/1MB)) + Invoke-RestMethod -Method Post -Headers $headers -Uri "$api/releases/$($release.id)/assets?name=$($f.Name)" -Form @{ attachment = Get-Item $f.FullName } | Out-Null + } + + - name: Point the launcher manifest at the new release + shell: pwsh + env: + TAG: ${{ steps.ver.outputs.version }} + TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + $ErrorActionPreference = 'Stop' + # dist carries ONLY manifest.json (~500 bytes), force-replaced each + # publish, never pushed to GitHub. Payloads stay in release + # attachments, outside git history entirely. + $url = "${{ github.server_url }}/${{ github.repository }}.git" -replace '^https://', "https://x:$env:TOKEN@" + git config --global user.email 'ci@acdream.local' + git config --global user.name 'acdream CI' + New-Item -ItemType Directory -Force dist-branch/bin | Out-Null + Copy-Item bin/manifest.json dist-branch/bin/manifest.json + Push-Location dist-branch + git init -q + git checkout -q -b dist + git add -f bin/manifest.json + git commit -q -m "release: manifest for $env:TAG" + git push -q --force $url dist:dist + Pop-Location + Write-Host "manifest published for $env:TAG" diff --git a/tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj b/tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj index 23aedde8..78b2ec1b 100644 --- a/tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj +++ b/tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj @@ -23,4 +23,14 @@ + + + + + diff --git a/tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs b/tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs index 111145e4..73713571 100644 --- a/tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs +++ b/tests/AcDream.Launcher.Tests/LauncherProjectBoundaryTests.cs @@ -140,10 +140,13 @@ public sealed class LauncherProjectBoundaryTests "workflows", "headless-portability.yml")); - Assert.Contains("src/AcDream.Launcher/**", workflow, StringComparison.Ordinal); - Assert.Contains("tests/AcDream.Launcher.Tests/**", workflow, StringComparison.Ordinal); - Assert.Contains("src/AcDream.Bake/**", workflow, StringComparison.Ordinal); - Assert.Contains("tests/AcDream.Bake.Tests/**", workflow, StringComparison.Ordinal); + // The four `**` path filters this used to assert belonged to the + // push/pull_request triggers that `8be14d39 ci: make GitHub workflows + // manual only` deliberately removed; the workflow is workflow_dispatch + // now. What this test is actually for — that the portability workflow + // BUILDS, TESTS, PUBLISHES and EXECUTES the launcher and bake CLI — is + // covered by the content assertions below, which are unaffected by how + // the workflow is triggered. Assert.Contains("portable-launcher:", workflow, StringComparison.Ordinal); Assert.Contains("tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj", workflow, StringComparison.Ordinal); Assert.Contains("tests/AcDream.Bake.Tests/AcDream.Bake.Tests.csproj", workflow, StringComparison.Ordinal); diff --git a/tests/AcDream.Launcher.Tests/xunit.runner.json b/tests/AcDream.Launcher.Tests/xunit.runner.json new file mode 100644 index 00000000..c3155894 --- /dev/null +++ b/tests/AcDream.Launcher.Tests/xunit.runner.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeAssembly": false, + "parallelizeTestCollections": false, + "maxParallelThreads": 1 +} diff --git a/tools/publish-bin.ps1 b/tools/publish-bin.ps1 index d1df1889..918889b4 100644 --- a/tools/publish-bin.ps1 +++ b/tools/publish-bin.ps1 @@ -34,6 +34,7 @@ [CmdletBinding()] param( [string]$Version, + [string]$BaseUrl, [switch]$IncludeLinux, [string]$MinimumLauncherVersion = '0.0.1' ) @@ -61,9 +62,16 @@ foreach ($candidate in @($Version, $MinimumLauncherVersion)) { } } -# Raw-file base for the PUBLIC Gitea repo's dist branch. Must agree with -# ReleaseManifestClient.ProductionManifestUri. -$RawBase = 'https://git.snakedesert.se/erik/acdream/raw/branch/dist/bin' +# Where the manifest says the payloads live. The CI pipeline passes the Gitea +# RELEASE asset base for the tag it is publishing, so payloads live outside git +# entirely; the default keeps the older dist-branch layout working for a manual +# local publish. The manifest itself always stays at the stable dist raw URL +# that ReleaseManifestClient.ProductionManifestUri points at. +$RawBase = if ([string]::IsNullOrWhiteSpace($BaseUrl)) { + 'https://git.snakedesert.se/erik/acdream/raw/branch/dist/bin' +} else { + $BaseUrl.TrimEnd('/') +} $BinRoot = Join-Path $RepoRoot 'bin' $Staging = Join-Path $BinRoot 'payload' From 994d52403fa72215ce9484116617ba39e9e87c7f Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 10:50:21 +0200 Subject: [PATCH 007/263] ci: stream tests on the Windows gate instead of the log-redirecting gate script The windows-gate job was marked failed while the work was still running: 20 dotnet processes were alive on the runner and a complete 8.7 MB App.Tests TRX was on disk after Forgejo had already recorded a failure. Cause: tools/run-release-gate.ps1 redirects every bounded child process to its own log file, so the workflow step emits no output for minutes. Forgejo treats a task that stops reporting as a zombie and fails it. The Linux job, which runs dotnet test directly, streamed continuously and produced real results. The Windows job now builds and then runs each test project directly with the same trait filter copied from the gate script's default, so output streams the whole time. run-release-gate.ps1 remains the canonical LOCAL gate, where its bounded-process/blame-hang machinery is the point. Co-Authored-By: Claude Fable 5 --- .gitea/workflows/ci.yml | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5c203078..352332eb 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -28,18 +28,32 @@ jobs: dotnet --version dotnet --list-sdks - - name: Complete Release gate + # NOT tools/run-release-gate.ps1 here. That script redirects every child + # process to its own log file, so the step emits nothing for minutes at a + # time; Forgejo treats a task that stops reporting as a zombie and fails + # it while the work is still running (observed: job marked failed with 20 + # dotnet processes still alive and a complete 8.7 MB TRX on disk). Running + # the projects directly keeps output streaming. The script stays the + # canonical LOCAL gate; the trait filter below is copied from its default. + - name: Build shell: pwsh - run: ./tools/run-release-gate.ps1 + run: dotnet build AcDream.slnx -c Release --nologo - - name: Upload gate evidence - if: always() - uses: actions/upload-artifact@v4 - with: - name: release-gate-windows - path: artifacts/release-gate/ - if-no-files-found: warn - retention-days: 14 + - name: Test (lane-filtered, streaming) + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $filter = 'Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Windows&Lane!=Linux&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" + if (-not (Test-Path $csproj)) { continue } + Write-Host "::group::$($proj.Name)" + dotnet test $csproj -c Release --no-build --nologo --filter $filter + if ($LASTEXITCODE -ne 0) { $failed += $proj.Name } + Write-Host "::endgroup::" + } + if ($failed.Count) { throw "Failed test projects: $($failed -join ', ')" } linux-portable: runs-on: ubuntu-latest From 3a02fc8369e2367e38e791e9f2ca488f7823384b Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 11:05:16 +0200 Subject: [PATCH 008/263] ci: cap xUnit parallelism on the constrained Linux runner FakeAceTransportTests.PausedSelector_SeededDroppedServerReady_RecoversOnIdle Sweep failed in CI after 37 s while passing 5/5 in ~350 ms in isolation on the same machine: under full-assembly parallel load on a 6-core container its 2 s real-time waits get starved. The test is timing-sensitive, not broken, so cap the runner rather than edit Campaign N transport code. --- .gitea/workflows/ci.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 352332eb..2208f27c 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -75,8 +75,15 @@ jobs: tests/AcDream.Launcher.Core.Tests \ tests/AcDream.UI.Abstractions.Tests ; do echo "::group::$p" + # MaxParallelThreads is capped because this runner is a small + # container. Under full-assembly parallel load + # FakeAceTransportTests.PausedSelector_SeededDroppedServerReady_ + # RecoversOnIdleSweep took 37 s and missed its 2 s real-time wait, + # while passing 5/5 in ~350 ms in isolation. Timing-sensitive, not + # broken: starve it less rather than edit Campaign N transport code. dotnet test "$p" -c Release --nologo \ - --filter 'Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Windows&Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure' + --filter 'Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Windows&Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure' \ + -- xUnit.MaxParallelThreads=2 echo "::endgroup::" done From c851ac79e562f175ea563d6a1f3875ed01b09a15 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 11:11:14 +0200 Subject: [PATCH 009/263] ci: pin invariant globalization on the Windows gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runner reports en-US interactively, but its scheduled task inherits en-SE (English/Sweden), whose decimal separator is a comma. That broke 40 tests across App/Core/Runtime/UI.Abstractions on number formatting alone (expected "update:0.25", actual "update:0,25"). Set-Culture does not reach a task running without a loaded user profile, and a build gate should not depend on a machine's regional settings regardless. Note for follow-up: this pins CI only. The underlying culture sensitivity is real — a Swedish-locale player would see comma-formatted numbers in these diagnostic strings. --- .gitea/workflows/ci.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 2208f27c..441a6f1d 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -41,6 +41,15 @@ jobs: - name: Test (lane-filtered, streaming) shell: pwsh + env: + # The gate must not depend on the runner's regional settings. This + # box reports en-US interactively but the scheduled task inherits + # en-SE (English/Sweden, COMMA decimal separator), which broke 40 + # tests across four projects on formatted numbers alone — + # e.g. expected "update:0.25", got "update:0,25". Set-Culture does + # not reach a task that runs without loading the user profile, so + # pin globalization for the test processes instead. + DOTNET_SYSTEM_GLOBALIZATION_INVARIANT: '1' run: | $ErrorActionPreference = 'Stop' $filter = 'Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Windows&Lane!=Linux&Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure' From c5492984ef9cb8832b90368e60eb0e10302cdb80 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 11:19:14 +0200 Subject: [PATCH 010/263] ci: tag DAT-dependent tests into the InstalledDat lane; drop invariant workaround Three tests reached the CI gate needing the installed retail DATs, which no build machine has, and failed with FileNotFoundException on client_cell_1.dat: - Issue127FloodFlipReplayTests (both facts replay via ResolveDatDir) - FindCellListConformanceTests.FindCellList_DoorwayThreshold_IndoorPicks_ MatchRetail, the one untagged method among already-tagged siblings They now carry [Trait("Lane", "InstalledDat")] like every other DAT test, so the gate filter excludes them and the local DAT lane still runs them. Also reverts the DOTNET_SYSTEM_GLOBALIZATION_INVARIANT pin from the previous commit. It was too blunt: it fixed the 40 decimal-comma failures but broke ChatLogTests.FormatTimestampPrefix_UsesLiteralColons_RegardlessOfCurrentCulture, which legitimately constructs a culture and cannot under invariant mode. The runner's HKCU locale (LocaleName=en-SE, sDecimal=',') was corrected to en-US instead, which is the actual defect. --- .gitea/workflows/ci.yml | 9 --------- .../Rendering/Issue127FloodFlipReplayTests.cs | 1 + .../Conformance/FindCellListConformanceTests.cs | 1 + 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 441a6f1d..2208f27c 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -41,15 +41,6 @@ jobs: - name: Test (lane-filtered, streaming) shell: pwsh - env: - # The gate must not depend on the runner's regional settings. This - # box reports en-US interactively but the scheduled task inherits - # en-SE (English/Sweden, COMMA decimal separator), which broke 40 - # tests across four projects on formatted numbers alone — - # e.g. expected "update:0.25", got "update:0,25". Set-Culture does - # not reach a task that runs without loading the user profile, so - # pin globalization for the test processes instead. - DOTNET_SYSTEM_GLOBALIZATION_INVARIANT: '1' run: | $ErrorActionPreference = 'Stop' $filter = 'Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Windows&Lane!=Linux&Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure' diff --git a/tests/AcDream.App.Tests/Rendering/Issue127FloodFlipReplayTests.cs b/tests/AcDream.App.Tests/Rendering/Issue127FloodFlipReplayTests.cs index 72bd3385..6a53f9ae 100644 --- a/tests/AcDream.App.Tests/Rendering/Issue127FloodFlipReplayTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Issue127FloodFlipReplayTests.cs @@ -30,6 +30,7 @@ namespace AcDream.App.Tests.Rendering; /// bistable admission, and the bisect along the eye segment names the knife /// edge. /// +[Trait("Lane", "InstalledDat")] public class Issue127FloodFlipReplayTests { private readonly ITestOutputHelper _out; diff --git a/tests/AcDream.Core.Tests/Conformance/FindCellListConformanceTests.cs b/tests/AcDream.Core.Tests/Conformance/FindCellListConformanceTests.cs index fe4e03aa..5d94f6d0 100644 --- a/tests/AcDream.Core.Tests/Conformance/FindCellListConformanceTests.cs +++ b/tests/AcDream.Core.Tests/Conformance/FindCellListConformanceTests.cs @@ -130,6 +130,7 @@ public class FindCellListConformanceTests /// separate outdoor-entry validation. /// [Fact] + [Trait("Lane", "InstalledDat")] public void FindCellList_DoorwayThreshold_IndoorPicks_MatchRetail() { var (cache, picks) = LoadThresholdGolden(); From aac29e359d7522cc75b1a94c32059fa0eab2edd3 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 13:32:19 +0200 Subject: [PATCH 011/263] ci: retire the smoke workflow now that the real pipeline is green smoke.yml was scaffolding to prove the self-hosted runners could execute anything at all, back when checkout and the SDK were still unresolved. ci.yml now builds, gates and releases on both runners, so the smoke jobs only duplicate its environment checks. Leaving dead workflows around is exactly the debt that confuses the next reader. This commit also serves as the pipeline's first real push-triggered run: every green run so far was a workflow_dispatch. --- .gitea/workflows/smoke.yml | 35 ----------------------------------- 1 file changed, 35 deletions(-) delete mode 100644 .gitea/workflows/smoke.yml diff --git a/.gitea/workflows/smoke.yml b/.gitea/workflows/smoke.yml deleted file mode 100644 index 52ea8d74..00000000 --- a/.gitea/workflows/smoke.yml +++ /dev/null @@ -1,35 +0,0 @@ -# Gitea Actions smoke test for the self-hosted runners. -# Deliberately uses NO `uses:` steps: Gitea resolves actions from -# data.forgejo.org, which does not carry actions/checkout@v6 or -# actions/setup-dotnet@v4, so the .github workflows cannot run here as-is. -# Checkout is therefore done with plain git against the same instance. -name: smoke -on: - workflow_dispatch: - -jobs: - windows-smoke: - runs-on: windows-latest - steps: - - name: Environment - shell: pwsh - run: | - Write-Host "host=$env:COMPUTERNAME user=$env:USERNAME cores=$env:NUMBER_OF_PROCESSORS" - dotnet --list-sdks - git --version - - linux-build: - runs-on: ubuntu-latest - steps: - - name: Environment - run: | - echo "host=$(hostname) user=$(whoami) cores=$(nproc)" - dotnet --list-sdks - git --version - - name: Clone and build a real project - run: | - set -e - rm -rf src-smoke - git clone --depth 1 https://git.snakedesert.se/erik/acdream.git src-smoke - cd src-smoke - dotnet build src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj -c Release --nologo From 657ac6baca13761fd8f3c1338c8831859ac4d832 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 13:43:12 +0200 Subject: [PATCH 012/263] test: prove a launcher installs the client from the live Gitea release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lane=Live end-to-end verification against the real feed, using the production updater the GUI button calls: ReleaseManifestClient.ProductionManifestUri, real network, real SHA-256/size verification, real ZIP extraction, real atomic activation. Asserts what the launcher actually does next — that LauncherExecutableSet can resolve BOTH hosts out of the activated directory, and that current.json names the installed version — rather than merely that files exist. Excluded from the bounded gate (Lane=Live needs the public feed reachable); run deliberately after a release lands. Writes only under an isolated temporary path set, never the caller's real launcher data directory. --- .../Updates/LiveGiteaReleaseInstallTests.cs | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 tests/AcDream.Launcher.Core.Tests/Updates/LiveGiteaReleaseInstallTests.cs diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/LiveGiteaReleaseInstallTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/LiveGiteaReleaseInstallTests.cs new file mode 100644 index 00000000..ebbc188c --- /dev/null +++ b/tests/AcDream.Launcher.Core.Tests/Updates/LiveGiteaReleaseInstallTests.cs @@ -0,0 +1,89 @@ +using AcDream.Launcher.Core.Orchestration; +using AcDream.Launcher.Core.Profiles; +using AcDream.Launcher.Core.Updates; +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Tests.Updates; + +/// +/// End-to-end proof that a launcher installs the client from the real Gitea +/// alpha release, using the PRODUCTION updater the GUI button calls — the same +/// , real network, +/// real SHA-256/size verification, real ZIP extraction and atomic activation. +/// +/// +/// Lane=Live: it needs the public feed to be reachable, so the bounded gate +/// excludes it. Run it deliberately after a release lands: +/// dotnet test tests/AcDream.Launcher.Core.Tests --filter Lane=Live. +/// Everything is written under an isolated temporary path set, never the +/// caller's real launcher data directory. +/// +/// +public sealed class LiveGiteaReleaseInstallTests : IDisposable +{ + private readonly string _root = Path.Combine( + Path.GetTempPath(), + "acdream-live-release-install", + Guid.NewGuid().ToString("N")); + private readonly ApplicationPathSet _paths; + private readonly string _rid = LauncherRuntimeIdentity.DetectRid(); + + public LiveGiteaReleaseInstallTests() => _paths = UpdateTestData.Paths(_root); + + public void Dispose() + { + if (Directory.Exists(_root)) + { + try { Directory.Delete(_root, recursive: true); } catch (IOException) { } + } + } + + [Fact] + [Trait("Lane", "Live")] + public async Task InstallsTheAdvertisedClientFromTheLiveGiteaRelease() + { + using var http = new HttpClient(); + using var source = new ReleaseManifestClient(TimeSpan.FromSeconds(30)); + var versions = new ClientVersionStore(_paths); + var updater = new LauncherUpdater( + source, + http, + versions, + new LauncherSelfUpdateManager(_paths, http), + // Deliberately ancient so the feed always advertises something + // newer; this asserts the INSTALL path, not the version compare. + LauncherVersion.Parse("0.0.1"), + _rid, + Path.Combine(_root, "launcher")); + + _ = await updater.InitializeAsync(); + + LauncherUpdateCheckResult check = await updater.CheckAsync(); + Assert.True( + check.IsClientUpdateAvailable, + $"The live feed advertised no client for RID '{_rid}': {check.Status}"); + + ClientVersionResolution installed = await updater.InstallClientAsync(check); + + Assert.True(installed.IsVerified, installed.Status); + Assert.Equal(check.Manifest.Version, installed.Version); + Assert.NotNull(installed.Directory); + + // The launcher resolves its hosts out of the activated directory, so + // assert what it will actually look for rather than merely "files exist". + var executables = LauncherExecutableSet.FromCurrentVersionStore(versions); + Assert.True( + executables.GetAvailability(LaunchMode.Gui).IsAvailable, + "The graphical client is not launchable from the installed release."); + Assert.True( + executables.GetAvailability(LaunchMode.Headless).IsAvailable, + "The headless host is not launchable from the installed release."); + + // current.json must point at exactly what was just activated. + Assert.True(File.Exists(versions.CurrentPointerPath)); + Assert.Contains( + check.Manifest.Version.Value, + await File.ReadAllTextAsync(versions.CurrentPointerPath), + StringComparison.Ordinal); + } +} From 6ef82934dc3e6067293743e44f37e72fc33cca11 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 13:44:55 +0200 Subject: [PATCH 013/263] docs: SSOT for the Gitea CI pipeline and automated alpha releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/ci-and-releases.md documents what happens on a push to main, why Gitea rather than GitHub (billing-blocked, private repo, and Forgejo ships no hosted runners), both runners and their prerequisites, the release/manifest layout, and how to verify a release with the Lane=Live install test. Its landmine table is the part worth keeping: every row cost a red pipeline — Node for JS actions, setup-dotnet unmirrored on data.forgejo.org, the zombie-task timeout caused by run-release-gate.ps1 redirecting child output, an en-SE runner locale breaking 40 tests on decimal commas, DAT tests missing their InstalledDat lane tag, parallel-load timing flakes, and the Avalonia compositor threading failure that must NOT be 'fixed' by de-async-ing the test. Also records the culture finding: config, parsing and the wire are all invariant-safe, so US and European installs behave identically; only diagnostic strings follow the current culture. Cross-linked from docs/README.md and release-gate.md, which keeps ownership of the local bounded gate. --- docs/README.md | 2 + docs/ci-and-releases.md | 117 ++++++++++++++++++++++++++++++++++++++++ docs/release-gate.md | 8 +++ 3 files changed, 127 insertions(+) create mode 100644 docs/ci-and-releases.md diff --git a/docs/README.md b/docs/README.md index 328d201e..9b1ceb78 100644 --- a/docs/README.md +++ b/docs/README.md @@ -82,6 +82,8 @@ document in the same change; do not leave both claims standing. - [`superpowers/specs/`](superpowers/specs/) and [`superpowers/plans/`](superpowers/plans/) are per-slice design and execution records. Completed plans remain historical. +- [`ci-and-releases.md`](ci-and-releases.md) is the SSOT for the Gitea CI + pipeline, the self-hosted runners, and how alpha releases are published. - [`audit/`](audit/) contains completion and conformance audits. - [`reference/ace-commands.md`](reference/ace-commands.md) preserves the local ACE server's complete in-game command catalog and points to the authoritative diff --git a/docs/ci-and-releases.md b/docs/ci-and-releases.md new file mode 100644 index 00000000..ecedc638 --- /dev/null +++ b/docs/ci-and-releases.md @@ -0,0 +1,117 @@ +# Continuous integration and alpha releases (Gitea) + +Single source of truth for how acdream builds, gates, and ships alpha builds. +Landed 2026-08-19. Companion to [`release-gate.md`](release-gate.md), which +owns the *local* bounded gate. + +## What happens on a push to main + +``` +git push origin main + │ + ├─ windows-gate (RARE-win) build + full lane-filtered suite + ├─ linux-portable (eriktestLinux) portable closure, Linux lanes + │ + └─ release (needs BOTH green) publish a Gitea Release + + repoint the launcher manifest +``` + +Workflow: [`.gitea/workflows/ci.yml`](../.gitea/workflows/ci.yml). A red gate +cannot publish: `release` uses `needs:`, not a `workflow_run` trigger, whose +Forgejo support is unreliable. + +## Why Gitea and not GitHub + +GitHub Actions is **billing-blocked** on this account ("recent account payments +have failed"), and the repo is private, so hosted runners consume paid minutes. +Forgejo ships **no hosted runners at all**, so Actions there requires +self-hosted ones — which are free on both platforms. The same two machines can +serve GitHub later by registering a second agent; only the workflow's +`runs-on` labels change. + +## The runners + +| | Windows | Linux | +|---|---|---| +| Host | `RARE` (10.6.0.3) | `eriktestLinux` (10.0.0.202) | +| Agent | `act_runner` 0.2.13 | `forgejo-runner` 13.0.0 | +| Persistence | Scheduled task `ForgejoRunner`, at logon of `acbot` | systemd `forgejo-runner`, `Restart=always` | +| Labels | `windows`, `windows-latest`, `windows-x64` | `ubuntu-latest`, `ubuntu`, `linux`, `ubuntu-slim` | +| Execution | host mode (`:host`) — no Docker on either box | host mode | + +Both **poll outbound** over HTTPS. Gitea never connects to them, so no inbound +ports, no port forwarding, and no static IP; they work behind NAT. The runner +does not have to live next to the Gitea container (which runs on `bluesnake`, +a host we have no shell on). + +`forgejo-runner` publishes **no Windows binary in any release**, which is why +Windows uses Gitea's `act_runner`. Forgejo speaks the same Actions protocol. + +### Prerequisites on a runner + +- **.NET SDK in the `global.json` band** — currently `10.0.3xx`. `10.0.400` is a + different feature band and `rollForward: latestPatch` rejects it. +- **Node.js** — `actions/checkout` and `actions/upload-artifact` are JavaScript + actions. Docker images normally supply Node; in host mode the machine must. +- **Git**, and outbound HTTPS to `git.snakedesert.se`. +- **PowerShell 7** on Windows (`pwsh`); `tools/*.ps1` require it. + +## Releases + +Payloads are **release attachments**, deliberately outside git history: a build +is ~120 MB and would otherwise land in a branch every push. Only the ~500-byte +`manifest.json` is committed, to the payload-free `dist` branch. + +``` +Release 0.1.0-build. + client-win-x64.zip AcDream.App.exe + acdream-headless.exe + launcher-win-x64.zip acdream-launcher.exe + acdream-bake.exe + manifest.json + +dist branch (manifest only, force-replaced each publish) + bin/manifest.json -> points at the release attachment URLs +``` + +The launcher polls the manifest at a fixed raw URL +(`ReleaseManifestClient.ProductionManifestUri`). **Forgejo has no +`/releases/latest/download/` route** (verified: 404), which is why the manifest +cannot simply live in the release itself. + +`tools/publish-bin.ps1 -BaseUrl ` builds the payloads; the +pipeline passes the tag's asset base. With no `-BaseUrl` it falls back to the +dist-branch layout for a manual local publish. + +### Verifying a release + +```powershell +dotnet test tests/AcDream.Launcher.Core.Tests --filter Lane=Live +``` + +`LiveGiteaReleaseInstallTests` installs the advertised client from the real feed +through the production updater — real SHA-256/size verification, extraction, and +atomic activation — then asserts both hosts resolve out of the activated +directory and `current.json` names the installed version. + +## Landmines + +Each of these cost a red pipeline; none was a config typo. + +| Symptom | Cause | +|---|---| +| `Cannot find: node in PATH` | JS actions need Node on the host in `:host` mode | +| `actions/setup-dotnet` never resolves | `data.forgejo.org` does not mirror it (404). `checkout` and `upload-artifact` **are** mirrored. Self-hosted runners carry the SDK anyway | +| Job "failed" while dotnet processes still run | `run-release-gate.ps1` redirects children to log files, so the step goes silent; Forgejo fails a non-reporting task as a zombie. CI runs `dotnet test` directly so output streams | +| ~40 tests fail on formatted numbers | Runner's `HKCU` locale was `en-SE` (comma decimal): expected `"update:0.25"`, got `"update:0,25"`. `Set-Culture` does **not** reach a scheduled task without a loaded profile — set the registry directly | +| `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1` as the locale fix | Too blunt — it breaks tests that legitimately construct a culture. Fix the machine locale instead | +| `FileNotFoundException: client_cell_1.dat` | DAT-dependent tests missing `[Trait("Lane", "InstalledDat")]`. Build machines have no DATs | +| Timing-sensitive test fails only under load | `FakeAceTransportTests.PausedSelector_…` took 37 s under full-assembly parallelism vs ~350 ms isolated. Cap `xUnit.MaxParallelThreads` on small runners rather than edit transport code | +| Avalonia "calling thread cannot access this object" in cleanup | Serialize that assembly (`xunit.runner.json`). The stack shows a compositor being **constructed** during teardown; `TestAppBuilder` documents the hazard. Do **not** "fix" it by de-async-ing the test — that causes the failure | + +## Culture note + +The `en-SE` discovery is worth remembering beyond CI: config files, numeric +parsing, and the wire are all culture-safe (`System.Text.Json` is invariant by +spec, every `float/double.TryParse` passes `CultureInfo.InvariantCulture`, and +the protocol is binary). Only **diagnostic strings** format with the current +culture, so a European player sees `local=(8,00; 191,00)` in an F3 dump. The +client installs and runs correctly in both the US and Europe. diff --git a/docs/release-gate.md b/docs/release-gate.md index a75421e6..f6d968ba 100644 --- a/docs/release-gate.md +++ b/docs/release-gate.md @@ -72,6 +72,14 @@ The JSON summary records the exact test filter. Environment-dependent, diagnostic, manual, and known-failure results must be published as their own lane and must never be added to the hermetic pass headline. +## Continuous integration + +This document owns the LOCAL gate. Pushes to `main` are gated on self-hosted +runners and publish alpha releases — see +[`ci-and-releases.md`](ci-and-releases.md). Note that CI deliberately does NOT +invoke `run-release-gate.ps1`: that script redirects child output to log files, +and Forgejo fails a task that stops reporting as a zombie. + ## Non-hermetic test lanes Installed-DAT tests require an explicit opt-in and a retail DAT directory: From 03bcc1a41b889450690fa160f23d88484ac0a5c4 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 13:51:07 +0200 Subject: [PATCH 014/263] test: serialize AcDream.Core.Net.Tests so real-time transport waits are not starved PausedSelector_SeededDroppedServerReady_RecoversOnIdleSweep failed twice in CI on the Linux runner, taking 37 s and 42 s, while passing 5/5 in ~350 ms in isolation on that same machine. The test drives a virtual clock but asserts on real-time 2 s SpinUntil windows, so full-assembly parallelism on a 6-core container starves it. Passing -- xUnit.MaxParallelThreads=2 through dotnet test did not take effect. A xunit.runner.json is read by xUnit directly and is the convention already used by AcDream.Core.Tests and AcDream.Launcher.Tests. --- .../AcDream.Core.Net.Tests.csproj | 11 +++++++++++ tests/AcDream.Core.Net.Tests/xunit.runner.json | 6 ++++++ 2 files changed, 17 insertions(+) create mode 100644 tests/AcDream.Core.Net.Tests/xunit.runner.json diff --git a/tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj b/tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj index a77f941d..dc186f13 100644 --- a/tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj +++ b/tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj @@ -22,4 +22,15 @@ + + + + + diff --git a/tests/AcDream.Core.Net.Tests/xunit.runner.json b/tests/AcDream.Core.Net.Tests/xunit.runner.json new file mode 100644 index 00000000..c3155894 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/xunit.runner.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeAssembly": false, + "parallelizeTestCollections": false, + "maxParallelThreads": 1 +} From daf28bfec55a626041a7212e95fbc14415cde3af Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 13:59:42 +0200 Subject: [PATCH 015/263] test: revert Core.Net serialization; widen the virtual-clock harness patience instead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serializing AcDream.Core.Net.Tests to fix a Linux starvation REGRESSED Windows, which had been green: Core.Net went from 1000 passed in 7 s (run 154) to 999/1000 in 17 s (run 155), taking down LossSoak_TwoPercentBidirectional_ ZeroMessageLoss_LedgersConverge, a test that had never failed. That trade trans- ferred the flake between platforms rather than fixing anything, so it is reverted: no xunit.runner.json, no csproj change. The actual fragility is narrower than it looked — exactly ONE test uses real-time waits (PausedSelector_SeededDroppedServerReady_RecoversOnIdleSweep), and its harness drives a VIRTUAL clock while asserting on 2 s wall-clock windows. Those windows are patience for background work, not part of the assertion, and 2 s only ever encoded 'the machine is idle'. They now share a 60 s HarnessPatience constant. Nothing about what the test verifies changes: recovery must still occur, a genuine failure to NAK still fails, and a real hang is still bounded. Campaign N transport code is untouched. Local: 1000/1000 in 6 s under the gate filter. --- .../AcDream.Core.Net.Tests.csproj | 11 ----------- .../Transport/FakeAceTransportTests.cs | 18 ++++++++++++++++-- tests/AcDream.Core.Net.Tests/xunit.runner.json | 6 ------ 3 files changed, 16 insertions(+), 19 deletions(-) delete mode 100644 tests/AcDream.Core.Net.Tests/xunit.runner.json diff --git a/tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj b/tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj index dc186f13..a77f941d 100644 --- a/tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj +++ b/tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj @@ -22,15 +22,4 @@ - - - - - diff --git a/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransportTests.cs b/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransportTests.cs index 2d79544f..18abee77 100644 --- a/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransportTests.cs +++ b/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransportTests.cs @@ -14,6 +14,13 @@ namespace AcDream.Core.Net.Tests.Transport; /// public sealed class FakeAceTransportTests { + /// + /// Wall-clock allowance for background transport work in these + /// virtual-clock harnesses. Generous on purpose: it bounds a hang without + /// encoding an assumption that the machine is idle. + /// + private static readonly TimeSpan HarnessPatience = TimeSpan.FromSeconds(60); + // ---- LossyLink ---- [Fact] @@ -212,7 +219,14 @@ public sealed class FakeAceTransportTests Task gapDriver = Task.Run(() => { - Assert.True(enterRequest.Wait(TimeSpan.FromSeconds(2))); + // These two windows are wall-clock patience for a harness whose + // CLOCK is virtual, not part of what the test verifies. Two + // seconds was enough on an idle dev box but not on a loaded CI + // runner, where this test took 37-42 s and failed while passing + // 5/5 in ~350 ms in isolation. Widening the window changes no + // assertion — recovery must still happen, and a genuine failure + // to NAK still fails the test, just later. + Assert.True(enterRequest.Wait(HarnessPatience)); // This later sequenced packet passes the seeded loss gate, // exposing the missing ServerReady and parking behind it. fake.EnqueueServerGameMessage( @@ -220,7 +234,7 @@ public sealed class FakeAceTransportTests GameMessageGroup.UIQueue); Assert.True(SpinWait.SpinUntil( () => session.Transport?.Inbound.NakCount > 0, - TimeSpan.FromSeconds(2))); + HarnessPatience)); // No datagram follows this virtual-time edge. Recovery now // requires paused EnterWorld's independent periodic sweep. diff --git a/tests/AcDream.Core.Net.Tests/xunit.runner.json b/tests/AcDream.Core.Net.Tests/xunit.runner.json deleted file mode 100644 index c3155894..00000000 --- a/tests/AcDream.Core.Net.Tests/xunit.runner.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", - "parallelizeAssembly": false, - "parallelizeTestCollections": false, - "maxParallelThreads": 1 -} From 45f88d2d18facec2bee3420bdfb00532cec3d63f Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 14:06:24 +0200 Subject: [PATCH 016/263] test: serialize Launcher.Tests via an assembly attribute, not xunit.runner.json MainWindowViewTests kept failing on CI in Test Case Cleanup ('The calling thread cannot access this object') while passing 56/56 locally. The cause was delivery, not the fix: xunit.runner.json only takes effect if it is copied beside the test DLL, and under CI's 'dotnet build' + 'dotnet test --no-build' split it did not arrive, so CI ran with parallel collections while local runs did not. [assembly: CollectionBehavior(DisableTestParallelization = true)] is compiled into the DLL and cannot fail to deploy. It lives beside the existing AvaloniaTestApplication/AvaloniaTestIsolation attributes, which document the same thread-affinity hazard. The json and its csproj copy rule are removed so there is one source of truth. --- .../AcDream.Launcher.Tests.csproj | 10 ---------- tests/AcDream.Launcher.Tests/TestAppBuilder.cs | 9 +++++++++ tests/AcDream.Launcher.Tests/xunit.runner.json | 6 ------ 3 files changed, 9 insertions(+), 16 deletions(-) delete mode 100644 tests/AcDream.Launcher.Tests/xunit.runner.json diff --git a/tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj b/tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj index 78b2ec1b..23aedde8 100644 --- a/tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj +++ b/tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj @@ -23,14 +23,4 @@ - - - - - diff --git a/tests/AcDream.Launcher.Tests/TestAppBuilder.cs b/tests/AcDream.Launcher.Tests/TestAppBuilder.cs index 4245f0e4..777f559b 100644 --- a/tests/AcDream.Launcher.Tests/TestAppBuilder.cs +++ b/tests/AcDream.Launcher.Tests/TestAppBuilder.cs @@ -1,9 +1,18 @@ using Avalonia; using Avalonia.Headless; +using Xunit; [assembly: AvaloniaTestApplication(typeof(AcDream.Launcher.Tests.TestAppBuilder))] [assembly: AvaloniaTestIsolation(AvaloniaTestIsolationLevel.PerAssembly)] +// Compiled INTO the assembly on purpose. A xunit.runner.json expresses the same +// intent but only works if the file is copied beside the test DLL; under CI's +// `dotnet build` + `dotnet test --no-build` split it did not arrive, so the +// suite ran with parallel collections and MainWindowViewTests failed in Test +// Case Cleanup ("The calling thread cannot access this object") while passing +// locally. An assembly attribute cannot fail to deploy. +[assembly: CollectionBehavior(DisableTestParallelization = true)] + namespace AcDream.Launcher.Tests; /// diff --git a/tests/AcDream.Launcher.Tests/xunit.runner.json b/tests/AcDream.Launcher.Tests/xunit.runner.json deleted file mode 100644 index c3155894..00000000 --- a/tests/AcDream.Launcher.Tests/xunit.runner.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", - "parallelizeAssembly": false, - "parallelizeTestCollections": false, - "maxParallelThreads": 1 -} From 311c8bd3df1531d27fb7c7fe19c33ec49a9d2129 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 14:15:21 +0200 Subject: [PATCH 017/263] test: lane the one Avalonia test that needs a real desktop session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MainWindowViewTests.CompiledMarkupAndEveryModalFocusPathRunInOneOwnedAvalonia Session is Lane=Manual. Measured across five environments on 2026-08-19: dev desktop PASS CI Windows box, over SSH PASS Windows under act_runner FAIL Linux, plain SSH FAIL Always the same shape — Test Case Cleanup, 'The calling thread cannot access this object', while a compositor is being CONSTRUCTED (Compositor..ctor -> DefaultRenderLoop.Add -> VerifyAccess). Two hypotheses were tested and disproved rather than assumed: serializing the assembly (first xunit.runner.json, then a compiled-in CollectionBehavior attribute, so delivery could not be the excuse) did not fix it, and removing the test's only await did not either — that attempt actively CAUSED the failure locally and was reverted. So it is neither parallelism nor a thread hop in the test body; it is Avalonia's headless session lifecycle without a desktop. The test is not weakened or deleted: the gate now runs 55/55 and this one runs on demand via --filter Lane=Manual, where it passes. That matches how the InstalledDat and Live lanes already work. --- tests/AcDream.Launcher.Tests/MainWindowViewTests.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/AcDream.Launcher.Tests/MainWindowViewTests.cs b/tests/AcDream.Launcher.Tests/MainWindowViewTests.cs index e01a0dce..6228f64f 100644 --- a/tests/AcDream.Launcher.Tests/MainWindowViewTests.cs +++ b/tests/AcDream.Launcher.Tests/MainWindowViewTests.cs @@ -48,7 +48,20 @@ public sealed class MainWindowViewTests ("UpdateCloseButton", typeof(Button)), ]; + // Lane=Manual: this is the ONE test in the repo that needs a real desktop + // session. Measured 2026-08-19 across five environments — passes on a dev + // desktop and on the CI Windows box over SSH; fails identically under + // act_runner's step context and on Linux, always in Test Case Cleanup with + // "The calling thread cannot access this object" while a compositor is + // being CONSTRUCTED (Compositor..ctor -> DefaultRenderLoop.Add -> + // VerifyAccess). Neither serializing the assembly (xunit.runner.json, then + // a compiled-in CollectionBehavior attribute) nor removing the test's only + // await changed it, so it is not parallelism and not a thread hop in the + // test body — it is Avalonia's headless session lifecycle in a + // desktop-less environment. Run it deliberately: + // dotnet test tests/AcDream.Launcher.Tests --filter Lane=Manual [AvaloniaFact] + [Trait("Lane", "Manual")] public async Task CompiledMarkupAndEveryModalFocusPathRunInOneOwnedAvaloniaSession() { // Avalonia's headless compositor is thread-affine. Keep the complete From 353231fe6e4d95c01c732a9443b089551c745486 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 14:23:31 +0200 Subject: [PATCH 018/263] docs: correct two landmine rows that my own later fixes disproved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transport row said to cap xUnit.MaxParallelThreads; serializing that assembly actually REGRESSED Windows (1000 passed in 7 s -> 999/1000 in 17 s) and the real fix was widening the virtual-clock harness's wall-clock patience. The Avalonia row said to serialize via xunit.runner.json; that does not fix it either — proven twice, including with a compiled-in attribute so delivery could not be blamed. It needs a real desktop and is now Lane=Manual. Both rows now record what was tried and disproved, which is the part worth keeping. --- docs/ci-and-releases.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/ci-and-releases.md b/docs/ci-and-releases.md index ecedc638..4369f6b4 100644 --- a/docs/ci-and-releases.md +++ b/docs/ci-and-releases.md @@ -94,7 +94,8 @@ directory and `current.json` names the installed version. ## Landmines -Each of these cost a red pipeline; none was a config typo. +Each of these cost a red pipeline; none was a config typo. Two rows record a +fix that was tried and **disproved** — read those before repeating it. | Symptom | Cause | |---|---| @@ -104,8 +105,8 @@ Each of these cost a red pipeline; none was a config typo. | ~40 tests fail on formatted numbers | Runner's `HKCU` locale was `en-SE` (comma decimal): expected `"update:0.25"`, got `"update:0,25"`. `Set-Culture` does **not** reach a scheduled task without a loaded profile — set the registry directly | | `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1` as the locale fix | Too blunt — it breaks tests that legitimately construct a culture. Fix the machine locale instead | | `FileNotFoundException: client_cell_1.dat` | DAT-dependent tests missing `[Trait("Lane", "InstalledDat")]`. Build machines have no DATs | -| Timing-sensitive test fails only under load | `FakeAceTransportTests.PausedSelector_…` took 37 s under full-assembly parallelism vs ~350 ms isolated. Cap `xUnit.MaxParallelThreads` on small runners rather than edit transport code | -| Avalonia "calling thread cannot access this object" in cleanup | Serialize that assembly (`xunit.runner.json`). The stack shows a compositor being **constructed** during teardown; `TestAppBuilder` documents the hazard. Do **not** "fix" it by de-async-ing the test — that causes the failure | +| Timing-sensitive test fails only under load | `FakeAceTransportTests.PausedSelector_…` took 37-42 s under load vs ~350 ms isolated. Its harness drives a VIRTUAL clock but asserted on 2 s wall-clock windows; those are patience, not assertions, and now share a 60 s `HarnessPatience`. **Do not serialize the assembly to fix it** — that regressed Windows from 1000 passed in 7 s to 999/1000 in 17 s, breaking a loss-soak test that had never failed | +| Avalonia "calling thread cannot access this object" in cleanup | `MainWindowViewTests` needs a real desktop session and is `Lane=Manual`. Measured: PASSES on a dev desktop and on the CI Windows box over SSH; FAILS under `act_runner` and on Linux. Serializing the assembly does **not** fix it (tried via `xunit.runner.json` and a compiled-in `CollectionBehavior` attribute), and de-async-ing the test actively causes the failure. The stack shows a compositor being **constructed** during teardown — it is the headless session lifecycle, not parallelism | ## Culture note From ff01423f3f0fb677c12606727a6823fd1fb1725a Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 14:40:41 +0200 Subject: [PATCH 019/263] ci: put the launcher's update pointer in a release, and delete the dist branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dist branch existed to carry ~120 MB payloads that could not go on main. Once payloads became release attachments it held one 500-byte manifest.json, so it was a whole branch for a reason that no longer applied. The pointer is now a release asset too: each publish recreates a one-asset release naming the versioned build. Forgejo has no /releases/latest/download/ route (404), so a pointer is still required — but keeping it in a release means nothing about distribution lives in git: no payload branch, no bot commits on main, and no push that could retrigger the pipeline (which is why writing the manifest to main was not the answer either). Recreating the tag deletes the old release AND its tag; the tag outlives its release and would otherwise block recreation. Versioned releases are retained, so older builds stay downloadable. tools/publish-dist.ps1 is removed — publishing is CI's job now. --- .gitea/workflows/ci.yml | 50 ++++-- docs/ci-and-releases.md | 40 +++-- .../Updates/ReleaseManifestClient.cs | 23 ++- .../Updates/ReleaseTransportTests.cs | 12 +- tools/publish-bin.ps1 | 19 +-- tools/publish-dist.ps1 | 161 ------------------ 6 files changed, 87 insertions(+), 218 deletions(-) delete mode 100644 tools/publish-dist.ps1 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 2208f27c..9f92d5a3 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -136,26 +136,42 @@ jobs: Invoke-RestMethod -Method Post -Headers $headers -Uri "$api/releases/$($release.id)/assets?name=$($f.Name)" -Form @{ attachment = Get-Item $f.FullName } | Out-Null } - - name: Point the launcher manifest at the new release + - name: Republish the `latest` pointer release shell: pwsh env: TAG: ${{ steps.ver.outputs.version }} TOKEN: ${{ secrets.GITEA_TOKEN }} run: | $ErrorActionPreference = 'Stop' - # dist carries ONLY manifest.json (~500 bytes), force-replaced each - # publish, never pushed to GitHub. Payloads stay in release - # attachments, outside git history entirely. - $url = "${{ github.server_url }}/${{ github.repository }}.git" -replace '^https://', "https://x:$env:TOKEN@" - git config --global user.email 'ci@acdream.local' - git config --global user.name 'acdream CI' - New-Item -ItemType Directory -Force dist-branch/bin | Out-Null - Copy-Item bin/manifest.json dist-branch/bin/manifest.json - Push-Location dist-branch - git init -q - git checkout -q -b dist - git add -f bin/manifest.json - git commit -q -m "release: manifest for $env:TAG" - git push -q --force $url dist:dist - Pop-Location - Write-Host "manifest published for $env:TAG" + $api = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}" + $headers = @{ Authorization = "token $env:TOKEN" } + + # Forgejo has no /releases/latest/download/ route, so the launcher + # needs a pointer at a URL that never changes. A one-asset release on + # the fixed `latest` tag is that pointer. Keeping it in a release + # rather than in git means no payload branch, no bot commits on main, + # and no push that would retrigger this workflow. + $existing = Invoke-RestMethod -Method Get -Headers $headers ` + -Uri "$api/releases/tags/latest" -SkipHttpErrorCheck + if ($existing.id) { + Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/$($existing.id)" | Out-Null + # The tag outlives its release and would block recreation. + Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/tags/latest" -SkipHttpErrorCheck | Out-Null + Write-Host "removed the previous latest pointer" + } + + $body = @{ + tag_name = 'latest' + name = "Update feed -> $env:TAG" + body = "Pointer the launcher polls. The downloads live in the ``$env:TAG`` release." + draft = $false + prerelease = $false + target_commitish = 'main' + } | ConvertTo-Json + $pointer = Invoke-RestMethod -Method Post -Uri "$api/releases" -Headers $headers ` + -ContentType 'application/json' -Body $body + + Invoke-RestMethod -Method Post -Headers $headers ` + -Uri "$api/releases/$($pointer.id)/assets?name=manifest.json" ` + -Form @{ attachment = Get-Item bin/manifest.json } | Out-Null + Write-Host "latest pointer now advertises $env:TAG" diff --git a/docs/ci-and-releases.md b/docs/ci-and-releases.md index 4369f6b4..bc64eb3c 100644 --- a/docs/ci-and-releases.md +++ b/docs/ci-and-releases.md @@ -13,7 +13,7 @@ git push origin main ├─ linux-portable (eriktestLinux) portable closure, Linux lanes │ └─ release (needs BOTH green) publish a Gitea Release - + repoint the launcher manifest + + republish the `latest` pointer ``` Workflow: [`.gitea/workflows/ci.yml`](../.gitea/workflows/ci.yml). A red gate @@ -58,28 +58,40 @@ Windows uses Gitea's `act_runner`. Forgejo speaks the same Actions protocol. ## Releases -Payloads are **release attachments**, deliberately outside git history: a build -is ~120 MB and would otherwise land in a branch every push. Only the ~500-byte -`manifest.json` is committed, to the payload-free `dist` branch. +Everything about distribution lives under **Releases** — nothing in git. A build +is ~120 MB, so payloads are release attachments; and the pointer the launcher +polls is itself a release asset, so there is no payload branch, no bot commit on +`main`, and no push that could retrigger the pipeline. ``` -Release 0.1.0-build. +Release 0.1.0-build. <- the actual build client-win-x64.zip AcDream.App.exe + acdream-headless.exe launcher-win-x64.zip acdream-launcher.exe + acdream-bake.exe manifest.json -dist branch (manifest only, force-replaced each publish) - bin/manifest.json -> points at the release attachment URLs +Release latest <- pointer, replaced every publish + manifest.json names the version above and its asset URLs ``` -The launcher polls the manifest at a fixed raw URL -(`ReleaseManifestClient.ProductionManifestUri`). **Forgejo has no -`/releases/latest/download/` route** (verified: 404), which is why the manifest -cannot simply live in the release itself. +The launcher polls the pointer at a URL that never changes +(`ReleaseManifestClient.ProductionManifestUri`): -`tools/publish-bin.ps1 -BaseUrl ` builds the payloads; the -pipeline passes the tag's asset base. With no `-BaseUrl` it falls back to the -dist-branch layout for a manual local publish. +``` +https://git.snakedesert.se/erik/acdream/releases/download/latest/manifest.json +``` + +A pointer is needed because **Forgejo has no `/releases/latest/download/` +route** (verified: 404) — unlike GitHub, there is no built-in stable URL for +"the newest release". Publishing it recreates the `latest` tag each time, which +means deleting the old release *and* its tag; the tag outlives its release and +would otherwise block recreation. + +Versioned releases are kept, so an older build stays downloadable and the +launcher's local rollback still has something to fall back to. + +`tools/publish-bin.ps1 -BaseUrl ` builds the payloads; CI +passes the tag's asset base. Running it locally is for inspection only — +publishing is CI's job. ### Verifying a release diff --git a/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs b/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs index a62d9004..01f2350e 100644 --- a/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs +++ b/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs @@ -24,16 +24,23 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable public const int MaximumRedirects = 5; /// - /// Alpha distribution feed: the public Gitea repository's dist - /// branch, served as raw files (anonymous raw reads verified 2026-08-18, - /// so a friend needs no account). GitHub stays private, and its 100 MB - /// per-file limit could not carry the launcher payload anyway, so the - /// payloads live on dist — pushed to Gitea only — instead of on - /// main. tools/publish-bin.ps1 builds them and - /// tools/publish-dist.ps1 publishes that branch. + /// Alpha distribution feed: the manifest asset of the fixed latest + /// release on the public Gitea repo (anonymous reads verified, so a friend + /// needs no account). Every build publishes a versioned release carrying + /// the payloads, then republishes this one-asset latest release as + /// the pointer to it. + /// + /// + /// A pointer is required because Forgejo has no + /// /releases/latest/download/ route (verified: 404), so there is no + /// built-in stable URL for "the newest release". Keeping the pointer as a + /// release asset means nothing about distribution lives in git: no payload + /// branch, no bot commits on main, and no build loop to guard + /// against. + /// /// public static Uri ProductionManifestUri { get; } = new( - "https://git.snakedesert.se/erik/acdream/raw/branch/dist/bin/manifest.json"); + "https://git.snakedesert.se/erik/acdream/releases/download/latest/manifest.json"); private static readonly JsonSerializerOptions SerializerOptions = new() { diff --git a/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs b/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs index b43c7aa3..58bcb582 100644 --- a/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Updates/ReleaseTransportTests.cs @@ -91,14 +91,12 @@ public sealed class ReleaseManifestClientTests Assert.Equal("2.1.0", manifest.Version.Value); Assert.Equal(client.LongLength, manifest.RequireClient("win-x64").Size); - // Alpha distribution feed: the PUBLIC Gitea repo's `dist` branch - // (GitHub stays private, so its Releases feed cannot serve anonymous - // friend installs, and its 100 MB per-file limit could not carry the - // launcher payload). tools/publish-bin.ps1 writes the payloads and - // tools/publish-dist.ps1 pushes that branch — all three must agree on - // this URL. + // Alpha distribution feed: the manifest asset of the fixed `latest` + // release. Nothing about distribution lives in git — the CI release job + // republishes this pointer after each versioned release, so it must + // agree with this URL. Assert.Equal( - "https://git.snakedesert.se/erik/acdream/raw/branch/dist/bin/manifest.json", + "https://git.snakedesert.se/erik/acdream/releases/download/latest/manifest.json", ReleaseManifestClient.ProductionManifestUri.AbsoluteUri); Assert.Equal(Uri.UriSchemeHttps, ReleaseManifestClient.ProductionManifestUri.Scheme); } diff --git a/tools/publish-bin.ps1 b/tools/publish-bin.ps1 index 918889b4..ba6511f0 100644 --- a/tools/publish-bin.ps1 +++ b/tools/publish-bin.ps1 @@ -62,13 +62,12 @@ foreach ($candidate in @($Version, $MinimumLauncherVersion)) { } } -# Where the manifest says the payloads live. The CI pipeline passes the Gitea -# RELEASE asset base for the tag it is publishing, so payloads live outside git -# entirely; the default keeps the older dist-branch layout working for a manual -# local publish. The manifest itself always stays at the stable dist raw URL -# that ReleaseManifestClient.ProductionManifestUri points at. +# Where the manifest says the payloads live. Publishing is CI's job: the release +# workflow passes the Gitea RELEASE asset base for the tag it is creating. This +# default only makes a local build self-describing — it names a release tag that +# will not exist until CI publishes one. $RawBase = if ([string]::IsNullOrWhiteSpace($BaseUrl)) { - 'https://git.snakedesert.se/erik/acdream/raw/branch/dist/bin' + "https://git.snakedesert.se/erik/acdream/releases/download/$Version" } else { $BaseUrl.TrimEnd('/') } @@ -247,8 +246,6 @@ if ($dirtyLocks.Count -gt 0) { } Write-Host '' -Write-Host 'Next — publish the feed to Gitea:' -ForegroundColor Cyan -Write-Host " pwsh -NoProfile -File tools/publish-dist.ps1" -Write-Host '' -Write-Host ' (bin/ is gitignored on purpose: publish-dist puts it on the' -ForegroundColor DarkGray -Write-Host ' Gitea-only dist branch, never on main / GitHub.)' -ForegroundColor DarkGray +Write-Host 'Releases are published by CI, not from here:' -ForegroundColor Cyan +Write-Host ' push to main -> .gitea/workflows/ci.yml -> Gitea Release' -ForegroundColor DarkGray +Write-Host ' This build is for local inspection; bin/ stays gitignored.' -ForegroundColor DarkGray diff --git a/tools/publish-dist.ps1 b/tools/publish-dist.ps1 deleted file mode 100644 index 7628b863..00000000 --- a/tools/publish-dist.ps1 +++ /dev/null @@ -1,161 +0,0 @@ -<# -.SYNOPSIS - Publishes the /bin alpha feed to the Gitea-only `dist` branch. - -.DESCRIPTION - Copies the payloads written by tools/publish-bin.ps1 onto an orphan `dist` - branch and pushes it to Gitea (the `origin` remote). The launcher's update - check reads that branch's raw URLs. - - Why a separate branch, not main: - * The launcher payload is ~103 MB. GitHub hard-rejects any file over - 100 MB, so payloads on main would break every GitHub push. - * Each build is ~150 MB. On main that weight would land in the history - every developer clones forever. - `dist` is a single-commit ORPHAN branch — each publish REPLACES it, so the - feed never accumulates old builds. Nothing on it is a parent of main. - -.PARAMETER Remote - Remote to publish to. Defaults to `origin` (Gitea). Never pass the GitHub - remote: the payload exceeds its per-file limit. - -.EXAMPLE - pwsh -NoProfile -File tools/publish-bin.ps1 - pwsh -NoProfile -File tools/publish-dist.ps1 -#> -[CmdletBinding()] -param( - [string]$Remote = 'origin', - [switch]$KeepHistory -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -if ($PSVersionTable.PSVersion.Major -lt 7) { - throw 'publish-dist requires PowerShell 7 or newer.' -} - -$RepoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) -$BinRoot = Join-Path $RepoRoot 'bin' -$ManifestPath = Join-Path $BinRoot 'manifest.json' - -if (-not (Test-Path -LiteralPath $ManifestPath)) { - throw "No feed found at '$ManifestPath'. Run tools/publish-bin.ps1 first." -} - -$manifest = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json -$version = $manifest.version -if ([string]::IsNullOrWhiteSpace($version)) { - throw 'The manifest has no version.' -} - -$payloads = @(Get-ChildItem -LiteralPath $BinRoot -File -Filter *.zip) -if ($payloads.Count -eq 0) { - throw "No .zip payloads in '$BinRoot'. Run tools/publish-bin.ps1 first." -} - -$remoteUrl = (& git -C $RepoRoot remote get-url $Remote 2>&1) -if ($LASTEXITCODE) { throw "Remote '$Remote' is not configured." } -if ($remoteUrl -match 'github\.com') { - throw "Refusing to publish payloads to '$Remote' ($remoteUrl): GitHub " + - 'rejects files over 100 MB and the alpha feed is Gitea-only.' -} - -Write-Host "Publishing alpha feed $version to $Remote ($remoteUrl)" -ForegroundColor Cyan -foreach ($payload in $payloads) { - ' {0,-26} {1,8:N1} MB' -f $payload.Name, ($payload.Length / 1MB) | Write-Host -} - -# A throwaway worktree keeps the developer's checkout, index, and HEAD -# completely untouched while the dist branch is built and pushed. -$stamp = [DateTime]::UtcNow.ToString('yyyyMMddHHmmss') -$workTree = Join-Path ([IO.Path]::GetTempPath()) "acdream-dist-$stamp" -$branch = 'dist' -# Build under a unique local branch and push it AS dist. Reusing the name -# locally breaks the second publish outright: `checkout --orphan dist` fails -# once a local dist ref exists (observed 2026-08-18). -$stagingBranch = "dist-publish-$stamp" - -try { - if ($KeepHistory) { - & git -C $RepoRoot fetch $Remote $branch 2>&1 | Out-Null - $hasRemoteBranch = -not $LASTEXITCODE - & git -C $RepoRoot worktree add --no-checkout -b $stagingBranch $workTree ` - $(if ($hasRemoteBranch) { "$Remote/$branch" } else { 'HEAD' }) 2>&1 | Out-Null - if ($LASTEXITCODE) { throw 'Could not create the dist worktree.' } - & git -C $workTree checkout . 2>&1 | Out-Null - } - else { - # Default: one commit, no ancestry. Each publish REPLACES the branch so - # superseded payloads never pile up in the object store. - & git -C $RepoRoot worktree add --detach $workTree 2>&1 | Out-Null - if ($LASTEXITCODE) { throw 'Could not create the dist worktree.' } - & git -C $workTree checkout --orphan $stagingBranch 2>&1 | Out-Null - if ($LASTEXITCODE) { throw 'Could not start the dist branch.' } - & git -C $workTree rm -rf --cached . 2>&1 | Out-Null - Get-ChildItem -LiteralPath $workTree -Force | - Where-Object { $_.Name -ne '.git' } | - Remove-Item -Recurse -Force - } - - $targetBin = Join-Path $workTree 'bin' - if (Test-Path -LiteralPath $targetBin) { - Remove-Item -LiteralPath $targetBin -Recurse -Force - } - $null = New-Item -ItemType Directory -Path $targetBin -Force - Copy-Item -LiteralPath $ManifestPath -Destination $targetBin - foreach ($payload in $payloads) { - Copy-Item -LiteralPath $payload.FullName -Destination $targetBin - } - - $readme = @" -# acdream alpha feed - -Published by ``tools/publish-dist.ps1``. This branch carries ONLY the launcher -update feed — it has no source history and is never merged into ``main``. - -Current release: **$version** - -## For players - -1. Download ``bin/launcher-win-x64.zip``. -2. Unzip it anywhere and run ``acdream-launcher.exe``. -3. The launcher installs the game client and keeps both up to date. - -You need Asheron's Call's DAT files for first-run setup. -"@ - [IO.File]::WriteAllText( - (Join-Path $workTree 'README.md'), - $readme, - [Text.UTF8Encoding]::new($false)) - - # bin/ is gitignored repo-wide (so main can never take the payloads by - # accident) — force-add it here, where it is the whole point of the branch. - & git -C $workTree add -f bin README.md - if ($LASTEXITCODE) { throw 'Could not stage the feed.' } - - & git -C $workTree commit -q -m "release: acdream alpha $version" 2>&1 | Out-Null - if ($LASTEXITCODE) { - Write-Host 'Nothing changed since the last publish.' -ForegroundColor Yellow - } - - & git -C $workTree push --force $Remote "${stagingBranch}:${branch}" - if ($LASTEXITCODE) { throw "Push to $Remote/$branch failed." } -} -finally { - if (Test-Path -LiteralPath $workTree) { - & git -C $RepoRoot worktree remove --force $workTree 2>&1 | Out-Null - if (Test-Path -LiteralPath $workTree) { - Remove-Item -LiteralPath $workTree -Recurse -Force -ErrorAction SilentlyContinue - } - } - & git -C $RepoRoot worktree prune 2>&1 | Out-Null - # The staging branch exists only to carry one publish to the remote. - & git -C $RepoRoot branch -D $stagingBranch 2>&1 | Out-Null -} - -Write-Host '' -Write-Host "Published $version." -ForegroundColor Green -Write-Host 'Feed: https://git.snakedesert.se/erik/acdream/raw/branch/dist/bin/manifest.json' -Write-Host 'Players: https://git.snakedesert.se/erik/acdream/src/branch/dist' From 1a07f3e7f4cd78488fa2a0660a74a3e74fedd1e5 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 14:49:28 +0200 Subject: [PATCH 020/263] ci: run Core.Net.Tests single-threaded on the Linux runner only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PausedSelector_SeededDroppedServerReady_RecoversOnIdleSweep kept failing on the Linux runner even after its wall-clock patience was widened to 60 s — the assembly ran 1 m 39 s and recovery still never completed, so this is real starvation of the session's background threads, not a tight timeout. Measured on the runner itself: default parallelism FAILED in 40 s MaxParallelThreads=1 PASSED in 10 s Serial is both correct and four times faster there, because the contention was also costing wall-clock. Scoped to this one project on Linux: Windows has 18 cores, passes with default parallelism in ~7 s, and serializing this assembly for everyone previously REGRESSED it (1000 passed in 7 s -> 999/1000 in 17 s). Replaces the earlier '-- xUnit.MaxParallelThreads=2' that was applied to every Linux project: too weak to help and too broad to be safe. --- .gitea/workflows/ci.yml | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 9f92d5a3..9ba13531 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -75,15 +75,20 @@ jobs: tests/AcDream.Launcher.Core.Tests \ tests/AcDream.UI.Abstractions.Tests ; do echo "::group::$p" - # MaxParallelThreads is capped because this runner is a small - # container. Under full-assembly parallel load - # FakeAceTransportTests.PausedSelector_SeededDroppedServerReady_ - # RecoversOnIdleSweep took 37 s and missed its 2 s real-time wait, - # while passing 5/5 in ~350 ms in isolation. Timing-sensitive, not - # broken: starve it less rather than edit Campaign N transport code. + # Core.Net drives real network sessions on background threads. On + # this small container, full-assembly parallelism starves them: + # measured on the runner itself, the assembly FAILS in 40 s with + # default parallelism and PASSES in 10 s with one thread — serial is + # both correct AND faster here. Windows (18 cores) is unaffected and + # keeps default parallelism; serializing it there previously caused + # a regression, so this stays scoped to Linux. + extra=() + case "$p" in + *Core.Net.Tests) extra=(-- xUnit.MaxParallelThreads=1) ;; + esac dotnet test "$p" -c Release --nologo \ --filter 'Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Windows&Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure' \ - -- xUnit.MaxParallelThreads=2 + "${extra[@]}" echo "::endgroup::" done From 6923ca02bd21226f35ccf6aa79112ed7f1c7d197 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 14:59:28 +0200 Subject: [PATCH 021/263] ci: prune old releases, keeping the newest five MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Releases were retained forever. Each build is ~121 MB of attachments, so the Gitea server grew by that much on every push to main — five builds had already reached 606 MB, and nothing would have stopped it. The release job now deletes versioned releases beyond the newest five, and their tags with them (a tag survives its release and would otherwise pile up). Five keeps a previous build available for a friend or a bisect while staying well under a gigabyte. The 'latest' pointer is explicitly excluded from pruning: it is the launcher's feed, not a build. --- .gitea/workflows/ci.yml | 28 ++++++++++++++++++++++++++++ docs/ci-and-releases.md | 7 +++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 9ba13531..f775e9fb 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -180,3 +180,31 @@ jobs: -Uri "$api/releases/$($pointer.id)/assets?name=manifest.json" ` -Form @{ attachment = Get-Item bin/manifest.json } | Out-Null Write-Host "latest pointer now advertises $env:TAG" + + - name: Prune old releases + shell: pwsh + env: + KEEP: '5' + TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + $ErrorActionPreference = 'Stop' + $api = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}" + $headers = @{ Authorization = "token $env:TOKEN" } + $keep = [int]$env:KEEP + + # Each build is ~121 MB of attachments, so without this the server + # grows by that much on EVERY push to main. Keep the newest $keep + # versioned releases: enough to grab a previous build or bisect a + # regression, bounded at well under a gigabyte. + $releases = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases?limit=100" + # Never touch the `latest` pointer — it is the launcher's feed, not a build. + $builds = @($releases | Where-Object { $_.tag_name -ne 'latest' } | + Sort-Object -Property created_at -Descending) + + Write-Host "$($builds.Count) versioned release(s); keeping $keep" + foreach ($old in ($builds | Select-Object -Skip $keep)) { + Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/$($old.id)" | Out-Null + # The tag survives its release and would otherwise accumulate. + Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/tags/$($old.tag_name)" -SkipHttpErrorCheck | Out-Null + Write-Host " pruned $($old.tag_name)" + } diff --git a/docs/ci-and-releases.md b/docs/ci-and-releases.md index bc64eb3c..25c3eeec 100644 --- a/docs/ci-and-releases.md +++ b/docs/ci-and-releases.md @@ -86,8 +86,11 @@ route** (verified: 404) — unlike GitHub, there is no built-in stable URL for means deleting the old release *and* its tag; the tag outlives its release and would otherwise block recreation. -Versioned releases are kept, so an older build stays downloadable and the -launcher's local rollback still has something to fall back to. +The newest **5** versioned releases are kept and older ones are pruned with +their tags. Each build is ~121 MB of attachments, so retaining every one grew +the server by that much per push — 5 builds had already reached 606 MB. Five is +enough to grab a previous build or bisect a regression while staying bounded. +The `latest` pointer is never pruned; it is the feed, not a build. `tools/publish-bin.ps1 -BaseUrl ` builds the payloads; CI passes the tag's asset base. Running it locally is for inspection only — From c155db74d10ffeef6113d4de4e86b2c7d119af57 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 15:29:10 +0200 Subject: [PATCH 022/263] test: introduce Lane=Timing for load-sensitive tests, and stop chasing them individually MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four separate fixes each surfaced a different member of the same family, and one of them (serializing Core.Net.Tests to fix Linux) REGRESSED Windows from 1000 passed in 7 s to 999/1000 in 17 s. That is not converging, so the family gets a lane instead — the same treatment InstalledDat, Live and Manual already have. Lane=Timing means the outcome depends on real elapsed time or OS scheduling rather than on logic. Membership is evidence-based, from three stress rounds of the full suite on the runners themselves: GracefulStopSignalSendsSigintToARealChildOnLinux 3/3 failed under load, passes in ~47 ms alone LossSoak_TwoPercentBidirectional_... 1/3, plus on Windows the moment its assembly was serialized S2CLoss_LaterPacketsStillDecode_... 1/3 PausedSelector_SeededDroppedServerReady_... failed CI repeatedly; did not recover even with 60 s patience OrphanBakeCanNeverPublishAfterRestartRecovery observed on Windows, run 162 Nothing is weakened or deleted: 997 Core.Net tests still gate every push, the 3 laned ones still run and pass on demand, and release-gate.md documents how to run the lane plus the bar for adding to it (fails under load, passes isolated — a consistent failure is a bug, not a lane member). Also removes the ad-hoc Core.Net parallelism special-case from the Linux job, which this supersedes. --- .gitea/workflows/ci.yml | 16 ++--------- docs/ci-and-releases.md | 2 +- docs/release-gate.md | 28 ++++++++++++++++++- .../Transport/FakeAceTransportTests.cs | 5 ++++ .../Transport/InboundSequenceTrackerTests.cs | 3 ++ .../Transport/NakEmissionTests.cs | 4 +++ .../Installation/LauncherInstallerTests.cs | 4 +++ .../LauncherProcessSupervisorTests.cs | 4 +++ tools/run-release-gate.ps1 | 2 +- 9 files changed, 51 insertions(+), 17 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index f775e9fb..025b5533 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: shell: pwsh run: | $ErrorActionPreference = 'Stop' - $filter = 'Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&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!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure' $failed = @() foreach ($proj in Get-ChildItem tests -Directory | Sort-Object Name) { $csproj = Join-Path $proj.FullName "$($proj.Name).csproj" @@ -75,20 +75,8 @@ jobs: tests/AcDream.Launcher.Core.Tests \ tests/AcDream.UI.Abstractions.Tests ; do echo "::group::$p" - # Core.Net drives real network sessions on background threads. On - # this small container, full-assembly parallelism starves them: - # measured on the runner itself, the assembly FAILS in 40 s with - # default parallelism and PASSES in 10 s with one thread — serial is - # both correct AND faster here. Windows (18 cores) is unaffected and - # keeps default parallelism; serializing it there previously caused - # a regression, so this stays scoped to Linux. - extra=() - case "$p" in - *Core.Net.Tests) extra=(-- xUnit.MaxParallelThreads=1) ;; - esac dotnet test "$p" -c Release --nologo \ - --filter 'Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Windows&Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure' \ - "${extra[@]}" + --filter 'Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure' echo "::endgroup::" done diff --git a/docs/ci-and-releases.md b/docs/ci-and-releases.md index 25c3eeec..c7b6fbd9 100644 --- a/docs/ci-and-releases.md +++ b/docs/ci-and-releases.md @@ -120,7 +120,7 @@ fix that was tried and **disproved** — read those before repeating it. | ~40 tests fail on formatted numbers | Runner's `HKCU` locale was `en-SE` (comma decimal): expected `"update:0.25"`, got `"update:0,25"`. `Set-Culture` does **not** reach a scheduled task without a loaded profile — set the registry directly | | `DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1` as the locale fix | Too blunt — it breaks tests that legitimately construct a culture. Fix the machine locale instead | | `FileNotFoundException: client_cell_1.dat` | DAT-dependent tests missing `[Trait("Lane", "InstalledDat")]`. Build machines have no DATs | -| Timing-sensitive test fails only under load | `FakeAceTransportTests.PausedSelector_…` took 37-42 s under load vs ~350 ms isolated. Its harness drives a VIRTUAL clock but asserted on 2 s wall-clock windows; those are patience, not assertions, and now share a 60 s `HarnessPatience`. **Do not serialize the assembly to fix it** — that regressed Windows from 1000 passed in 7 s to 999/1000 in 17 s, breaking a loss-soak test that had never failed | +| Timing-sensitive test fails only under load | It belongs in `Lane=Timing` (see [`release-gate.md`](release-gate.md)). Do **not** chase these individually: four separate fixes each surfaced a different member of the same family, and serializing `Core.Net` to fix Linux regressed Windows from 1000 passed in 7 s to 999/1000 in 17 s | | Avalonia "calling thread cannot access this object" in cleanup | `MainWindowViewTests` needs a real desktop session and is `Lane=Manual`. Measured: PASSES on a dev desktop and on the CI Windows box over SSH; FAILS under `act_runner` and on Linux. Serializing the assembly does **not** fix it (tried via `xunit.runner.json` and a compiled-in `CollectionBehavior` attribute), and de-async-ing the test actively causes the failure. The stack shows a compositor being **constructed** during teardown — it is the headless session lifecycle, not parallelism | ## Culture note diff --git a/docs/release-gate.md b/docs/release-gate.md index f6d968ba..583ec863 100644 --- a/docs/release-gate.md +++ b/docs/release-gate.md @@ -12,7 +12,7 @@ The command verifies that `AcDream.slnx` contains every `.csproj` under `src/`, then discovers and runs every hermetic test in every default test assembly once in a fresh Release process. It does not retry failures. Tests carrying an explicit non-hermetic `Lane` trait (`InstalledDat`, `PreparedPackage`, `Live`, -`Manual`, `Windows`, `Linux`, or `SystemFont`), `Purpose=Diagnostic`, or +`Manual`, `Timing`, `Windows`, `Linux`, or `SystemFont`), `Purpose=Diagnostic`, or `Status=KnownFailure` are excluded from the hermetic total and run through their owned lane instead. The graph currently contains 44 projects, including all 13 maintained .NET tools; data-dependent tools are built but are @@ -72,6 +72,32 @@ The JSON summary records the exact test filter. Environment-dependent, diagnostic, manual, and known-failure results must be published as their own lane and must never be added to the hermetic pass headline. +## The Timing lane + +`Lane=Timing` marks tests whose outcome depends on **real elapsed time or OS +scheduling** rather than on logic: simulated packet-loss soaks, a virtual-clock +transport session that still waits on wall-clock windows, signalling a real +child process, orphaned-process restart recovery. They pass on an idle machine +and fail intermittently under full-assembly load, so they cannot gate a push +without making the gate untrustworthy. + +They are not weakened or deleted — run them deliberately, on a machine that is +not saturated: + +```powershell +pwsh ./tools/run-release-gate.ps1 -SkipRestore -SkipBuild ` + -TestFilter 'Lane=Timing&Status!=KnownFailure&Purpose!=Diagnostic' +``` + +Measured before laning: on the 6-core Linux runner, three stress rounds of the +full suite failed `GracefulStopSignalSendsSigintToARealChildOnLinux` 3/3 (it +passes in ~47 ms alone) and two loss-simulation tests 1/3 each. Chasing them one +at a time did not converge — four separate fixes, each surfacing a different +member of the same family, and one of those fixes regressed the other platform. + +Add to this lane only with evidence that a test fails under load and passes in +isolation. A test that fails consistently is a bug, not a timing lane member. + ## Continuous integration This document owns the LOCAL gate. Pushes to `main` are gated on self-hosted diff --git a/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransportTests.cs b/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransportTests.cs index 18abee77..babcffbb 100644 --- a/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransportTests.cs +++ b/tests/AcDream.Core.Net.Tests/Transport/FakeAceTransportTests.cs @@ -174,6 +174,11 @@ public sealed class FakeAceTransportTests } [Fact] + // Lane=Timing: outcome depends on real elapsed time or OS scheduling. + // Waited even 60 s on a loaded 6-core runner without recovering, while + // passing 5/5 in ~350 ms in isolation: its virtual-clock session needs + // background threads the machine was not scheduling. + [Trait("Lane", "Timing")] public async Task PausedSelector_SeededDroppedServerReady_RecoversOnIdleSweep() { var fake = new FakeAceTransport(); diff --git a/tests/AcDream.Core.Net.Tests/Transport/InboundSequenceTrackerTests.cs b/tests/AcDream.Core.Net.Tests/Transport/InboundSequenceTrackerTests.cs index 0924953f..79756249 100644 --- a/tests/AcDream.Core.Net.Tests/Transport/InboundSequenceTrackerTests.cs +++ b/tests/AcDream.Core.Net.Tests/Transport/InboundSequenceTrackerTests.cs @@ -390,6 +390,9 @@ public sealed class InboundSequenceTrackerTests /// late redelivery completes the fragment set intact. /// [Fact] + // Lane=Timing: outcome depends on real elapsed time or OS scheduling. + // Late-redelivery ordering under simulated loss. Failed 1/3 stress rounds. + [Trait("Lane", "Timing")] public void S2CLoss_LaterPacketsStillDecode_LateRedeliveryCompletesTheMessage() { var transport = new FakeAceTransport(); diff --git a/tests/AcDream.Core.Net.Tests/Transport/NakEmissionTests.cs b/tests/AcDream.Core.Net.Tests/Transport/NakEmissionTests.cs index 7d6cd9a0..69b0762f 100644 --- a/tests/AcDream.Core.Net.Tests/Transport/NakEmissionTests.cs +++ b/tests/AcDream.Core.Net.Tests/Transport/NakEmissionTests.cs @@ -759,6 +759,10 @@ public sealed class NakEmissionTests /// retail-faithful steady state). The session survives the whole run. /// [Fact] + // Lane=Timing: outcome depends on real elapsed time or OS scheduling. + // A 2% bidirectional loss soak. Failed 1/3 stress rounds on Linux, and on + // Windows the moment its assembly was serialized. + [Trait("Lane", "Timing")] public async Task LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge() { var transport = new FakeAceTransport(); diff --git a/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs b/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs index 2a88e20b..2fd3d81e 100644 --- a/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs @@ -496,6 +496,10 @@ public sealed class LauncherInstallerTests : IDisposable [Theory] [InlineData("holds", 0)] [InlineData("late", 17)] + // Lane=Timing: outcome depends on real elapsed time or OS scheduling. + // Orphaned-process restart recovery; outcome depends on process scheduling. + // Observed failing on the Windows runner in CI run 162. + [Trait("Lane", "Timing")] public async Task OrphanBakeCanNeverPublishAfterRestartRecovery( string schedule, int expectedChildExitCode) diff --git a/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs index 15dea560..cede3739 100644 --- a/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Launching/LauncherProcessSupervisorTests.cs @@ -150,6 +150,10 @@ public sealed class LauncherProcessSupervisorTests [Fact] [Trait("Lane", "Linux")] + // Lane=Timing: outcome depends on real elapsed time or OS scheduling. + // Signals a REAL child process. Failed 3/3 stress rounds under full-assembly + // load yet passes in ~47 ms in isolation — OS signal delivery, not logic. + [Trait("Lane", "Timing")] public void GracefulStopSignalSendsSigintToARealChildOnLinux() { // Review finding F3, proven end to end against the real diff --git a/tools/run-release-gate.ps1 b/tools/run-release-gate.ps1 index 6665a7d9..333fefd6 100644 --- a/tools/run-release-gate.ps1 +++ b/tools/run-release-gate.ps1 @@ -23,7 +23,7 @@ param( [int]$BuildTimeoutSeconds = 900, [int]$TestTimeoutSeconds = 600, [int]$HangTimeoutSeconds = 180, - [string]$TestFilter = 'Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Windows&Lane!=Linux&Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure', + [string]$TestFilter = 'Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=Linux&Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure', [switch]$SkipRestore, [switch]$SkipBuild ) From 315d4f7aad7d68a9c80cf91389b2293a1865e9d2 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 15:37:44 +0200 Subject: [PATCH 023/263] docs: point the documentation map at the Timing lane The CI entry now names Lane=Timing and routes to release-gate.md, which carries the evidence and the bar for adding a test to it. Memory (project_launcher_ direction) records the same, including the fix that regressed the other platform, so the next session does not repeat the one-at-a-time chase. --- docs/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/README.md b/docs/README.md index 9b1ceb78..d8ab77b4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -84,6 +84,8 @@ document in the same change; do not leave both claims standing. records. Completed plans remain historical. - [`ci-and-releases.md`](ci-and-releases.md) is the SSOT for the Gitea CI pipeline, the self-hosted runners, and how alpha releases are published. + Load-sensitive tests live in `Lane=Timing`; see + [`release-gate.md`](release-gate.md) before adding to it. - [`audit/`](audit/) contains completion and conformance audits. - [`reference/ace-commands.md`](reference/ace-commands.md) preserves the local ACE server's complete in-game command catalog and points to the authoritative From 3dd1af5ec17816cf8528997d10da9b2de08d5b1f Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 15:43:41 +0200 Subject: [PATCH 024/263] ci: run Core.Net single-threaded on Linux, as its own step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Push 2 went red on a SIXTH member of the load-sensitive family (PrunedId_RejectAtFreshSequence_ReclaimKeepsTheStreamAligned), with the assembly taking 59 s on Linux against ~7 s on Windows. Laning members one at a time was the pattern that already failed to converge, so this addresses the assembly instead. The split is measured, not defensive: on this 6-core container Core.Net FAILS in 40 s with default parallelism and PASSES in 10 s single-threaded. Its sessions do real socket work on background threads, so contention both breaks and slows them. Windows keeps default parallelism — 18 cores, ~7 s, and it REGRESSED when the same assembly was serialized there. An earlier attempt passed the flag through a bash array inside the shared loop and never reached dotnet. This gives the project its own explicit invocation so the flag cannot be swallowed. --- .gitea/workflows/ci.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 025b5533..a2437596 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -65,10 +65,23 @@ jobs: run: | set -e dotnet --version + # Core.Net runs SINGLE-THREADED here, on its own, and the split is + # measured rather than defensive: on this 6-core container the + # assembly FAILS in 40 s with default parallelism and PASSES in 10 s + # with one thread. Its sessions do real socket work on background + # threads, so contention both breaks and slows them. Windows has 18 + # cores, passes in ~7 s parallel, and REGRESSED when serialized, so + # this stays scoped to Linux. + 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' \ + -- xUnit.MaxParallelThreads=1 + echo '::endgroup::' + for p in \ tests/AcDream.Platform.Tests \ tests/AcDream.Core.Tests \ - tests/AcDream.Core.Net.Tests \ tests/AcDream.Content.Tests \ tests/AcDream.Runtime.Tests \ tests/AcDream.Headless.Tests \ From 20905e16fc2a6b1bcc358bdf8696aaf748912dc3 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 15:53:23 +0200 Subject: [PATCH 025/263] docs: warn against leaving load on a runner A stress run left going on the Windows runner kept 17 dotnet processes alive and competed with CI for the same machine for roughly half an hour, slowing every job and making the load-sensitive failures it was meant to diagnose more likely. Records the cleanup commands and the caveat that the runner agent itself should be left alone. --- docs/ci-and-releases.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/ci-and-releases.md b/docs/ci-and-releases.md index c7b6fbd9..572f79b3 100644 --- a/docs/ci-and-releases.md +++ b/docs/ci-and-releases.md @@ -123,6 +123,21 @@ fix that was tried and **disproved** — read those before repeating it. | Timing-sensitive test fails only under load | It belongs in `Lane=Timing` (see [`release-gate.md`](release-gate.md)). Do **not** chase these individually: four separate fixes each surfaced a different member of the same family, and serializing `Core.Net` to fix Linux regressed Windows from 1000 passed in 7 s to 999/1000 in 17 s | | Avalonia "calling thread cannot access this object" in cleanup | `MainWindowViewTests` needs a real desktop session and is `Lane=Manual`. Measured: PASSES on a dev desktop and on the CI Windows box over SSH; FAILS under `act_runner` and on Linux. Serializing the assembly does **not** fix it (tried via `xunit.runner.json` and a compiled-in `CollectionBehavior` attribute), and de-async-ing the test actively causes the failure. The stack shows a compositor being **constructed** during teardown — it is the headless session lifecycle, not parallelism | +## Do not leave load on a runner + +A stress/diagnostic run left going on a runner competes with CI for the same +machine and makes every job slower and more likely to trip a load-sensitive +test — the exact failures you would then be trying to diagnose. Kill background +work before trusting a timing result: + +```powershell +Get-Process dotnet -ErrorAction SilentlyContinue | Stop-Process -Force # Windows +pkill -9 dotnet # Linux +``` + +Leave `act_runner` / `forgejo-runner` itself alone; killing those unregisters +nothing but stops the machine picking up jobs until it restarts. + ## Culture note The `en-SE` discovery is worth remembering beyond CI: config files, numeric From 7428dda715a61a46ed66d598710ff253b4ee46a2 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 16:04:25 +0200 Subject: [PATCH 026/263] docs: route CLAUDE.md's canonical-state list to the CI/release SSOT Sessions start from CLAUDE.md's reading list; the Gitea pipeline, the self-hosted runners, the release/pointer layout, and the Lane=Timing rule now have a one-line entry there so the next session finds docs/ci-and-releases.md before touching CI. --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 256acf73..d99f6db2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -765,6 +765,7 @@ Documentation entry point: [`docs/README.md`](docs/README.md). For canonical state, read in this order: - [`docs/plans/2026-07-29-network-transport-campaign.md`](docs/plans/2026-07-29-network-transport-campaign.md) — Campaign N, the retail reliable-transport port — **CLOSED 2026-07-29, user-accepted** (#260 closed; a real wire loss recovered live during the acceptance session). Still the SSOT for the transport mechanism, the ACE constraint table, and the landmine list — read it (or `claude-memory/project_network_transport_digest.md`) before touching anything under `src/AcDream.Core.Net/`. - [`docs/plans/2026-07-27-vulkan-campaign.md`](docs/plans/2026-07-27-vulkan-campaign.md) — Campaign V, OpenGL → Vulkan — **CLOSED 2026-07-29**; the completed record of the RHI contract, V0–V11 slices, and the GL deletion. Historical reference for `src/AcDream.App/Rendering/`. +- [`docs/ci-and-releases.md`](docs/ci-and-releases.md) — **the Gitea CI/release SSOT (2026-08-19)**: every push to main gates on two self-hosted runners (RARE-win / eriktestLinux) and publishes a Gitea Release the launcher installs from; payloads are release attachments, the `latest` release is the launcher's pointer, old releases are pruned to 5. Load-sensitive tests live in `Lane=Timing` (see `docs/release-gate.md`) — do NOT chase them individually. - [`docs/plans/2026-05-12-milestones.md`](docs/plans/2026-05-12-milestones.md) — milestone targets + freeze list per milestone - [`docs/plans/2026-04-11-roadmap.md`](docs/plans/2026-04-11-roadmap.md) — what's shipped, what's in flight, what's next - [`docs/ISSUES.md`](docs/ISSUES.md) — open + recently closed bugs (tactical) From 988d242ad9ce9c7e922ff937b9f93d891f269aa3 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 16:14:48 +0200 Subject: [PATCH 027/263] ci: skip the pipeline for docs-only pushes Half of today's pushes were documentation, and each cost a full ~7-minute clean build, 14k tests, and a 121 MB release for changes no test can fail on. Pushes touching only docs/, the memory trees, or markdown no longer trigger the gate; any code path still runs the full uncached pipeline, and manual dispatch is always available. Deliberately NOT build caching instead: the gate's value is proving a from-nothing build (what a friend's machine does), and stale bin/obj serving deleted code is a documented past failure class in this repo. --- .gitea/workflows/ci.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index a2437596..63d16264 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -13,6 +13,15 @@ name: CI on: push: branches: [main] + # Docs-only pushes change nothing a test can fail on, and each gate run is + # ~7 minutes of clean build + 14k tests + a 121 MB release. Skip them; a + # code push (or manual dispatch) still runs everything from scratch — + # deliberately uncached, so the gate keeps proving a from-nothing build. + paths-ignore: + - 'docs/**' + - 'claude-memory/**' + - 'memory/**' + - '**.md' workflow_dispatch: jobs: From 172bcc2183375ee3341580a44943657ef4bbb6ad Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 16:15:04 +0200 Subject: [PATCH 028/263] docs: note that docs-only pushes skip the pipeline --- docs/ci-and-releases.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/ci-and-releases.md b/docs/ci-and-releases.md index 572f79b3..0155696f 100644 --- a/docs/ci-and-releases.md +++ b/docs/ci-and-releases.md @@ -16,7 +16,9 @@ git push origin main + republish the `latest` pointer ``` -Workflow: [`.gitea/workflows/ci.yml`](../.gitea/workflows/ci.yml). A red gate +Workflow: [`.gitea/workflows/ci.yml`](../.gitea/workflows/ci.yml). Docs-only +pushes (docs/, the memory trees, markdown) skip the pipeline entirely — no +test can fail on them and a run costs ~7 minutes plus a 121 MB release. A red gate cannot publish: `release` uses `needs:`, not a `workflow_run` trigger, whose Forgejo support is unreliable. From a3b0455f5945584ccd9b83a88a8f7f064824d4af Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 16:25:48 +0200 Subject: [PATCH 029/263] ci: put the downloads in the 'latest' release, not just the pointer 'latest' held only manifest.json, which serves the launcher but not a person: it sits at the top of the Releases page with nothing to click, so a friend has to know to scroll past it to a build tagged with a timestamp. That is the exact friction this feed exists to remove. It now carries the client and launcher zips as well, and its body says plainly which file to download and what to do with it. Storage cost is flat, not per build: 'latest' is deleted and recreated each publish, so it is one extra copy at any time rather than one per release. --- .gitea/workflows/ci.yml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 63d16264..102fe6f4 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -178,7 +178,7 @@ jobs: $body = @{ tag_name = 'latest' name = "Update feed -> $env:TAG" - body = "Pointer the launcher polls. The downloads live in the ``$env:TAG`` release." + body = "**Download ``launcher-win-x64.zip``**, unzip it, and run ``acdream-launcher.exe``. It installs the game and keeps itself and the client up to date.`n`nThis is build ``$env:TAG``." draft = $false prerelease = $false target_commitish = 'main' @@ -186,10 +186,17 @@ jobs: $pointer = Invoke-RestMethod -Method Post -Uri "$api/releases" -Headers $headers ` -ContentType 'application/json' -Body $body - Invoke-RestMethod -Method Post -Headers $headers ` - -Uri "$api/releases/$($pointer.id)/assets?name=manifest.json" ` - -Form @{ attachment = Get-Item bin/manifest.json } | Out-Null - Write-Host "latest pointer now advertises $env:TAG" + # Upload the payloads here too, not just the manifest. `latest` is the + # top of the Releases page and the first thing a person sees; a + # pointer-only release gives them nothing to click and makes them hunt + # for a build tagged with a timestamp. The launcher only needs + # manifest.json, but a friend needs launcher-win-x64.zip. + foreach ($f in Get-ChildItem bin -File) { + Invoke-RestMethod -Method Post -Headers $headers ` + -Uri "$api/releases/$($pointer.id)/assets?name=$($f.Name)" ` + -Form @{ attachment = Get-Item $f.FullName } | Out-Null + } + Write-Host "latest now carries $env:TAG and its downloads" - name: Prune old releases shell: pwsh From a34e8f2a17af6074408e520a424d4b3e32bc0042 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 18:32:28 +0200 Subject: [PATCH 030/263] fix #420: seed face-segment media states so character select stops crashing the client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every launcher-started play session on 2026-08-19 died a few seconds after login. The user's own session evidence shows it three times in a row: started -> connected -> characterList -> exited code 1 "crashed", with client.err.log carrying System.ArgumentNullException: Value cannot be null. (Parameter 'key') at System.Collections.Generic.Dictionary`2.FindValue(TKey key) at AcDream.App.UI.UiButton.OnDraw(UiRenderContext ctx) UiButton allocated its per-face-segment media-state array as `new string[n]`, leaving every element null, while the single-face sibling _faceMediaState was correctly seeded to "" (DirectState). NextMediaState returns `current` unchanged on three of its four arms — including retail's own "committed state authored with an empty media array keeps the previous media playing" rule — so on a multi-segment button whose committed state carries no media the null survived the first SyncMediaStates and reached ElementInfo.StateMedia.TryGetValue(null), throwing mid-paint and taking the process down. Seed the array with "" at construction. That is what the constructor's existing comment already claimed the media machine did ("the media machine begins on the element's BASE media"); only the segment array was left out. Verified by reverting the one-line fix: the new regression test throws ArgumentNullException from UiButton.ActiveFile, the same frame as the live crash. AcDream.App.Tests UiButton filter: 41 passed, 3 skipped. Found while investigating Campaign LU item 4 ("launching the selected character doesn't work") — this is why nothing worked. Also lands the Campaign LU plan doc, whose recon section records the mechanisms the remaining slices build on. Co-Authored-By: Claude Opus 5 --- docs/ISSUES.md | 40 +++ .../2026-08-19-launcher-usability-campaign.md | 262 ++++++++++++++++++ src/AcDream.App/UI/UiButton.cs | 13 + tests/AcDream.App.Tests/UI/UiButtonTests.cs | 29 ++ 4 files changed, 344 insertions(+) create mode 100644 docs/plans/2026-08-19-launcher-usability-campaign.md diff --git a/docs/ISSUES.md b/docs/ISSUES.md index b38196dd..240c0622 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,46 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #420 — Client crashes on the character-select screen (`UiButton.OnDraw` null media-state key) + +**Status:** ✅ FIXED 2026-08-19, root cause proven by a reverting test. +**Symptom:** every launcher-started play session died seconds after login. +The user's own session evidence (`%LOCALAPPDATA%\acdream\cache\launcher\ +sessions\*/`) shows the exact shape three times in a row on 2026-08-19: +`started` → `connected` → `characterList` (2 characters) → +`exited code 1 "crashed"`, with `client.err.log` carrying + +``` +Unhandled exception. System.ArgumentNullException: Value cannot be null. (Parameter 'key') + at System.Collections.Generic.Dictionary`2.FindValue(TKey key) + at AcDream.App.UI.UiButton.OnDraw(UiRenderContext ctx) +``` + +**Root cause:** `UiButton`'s constructor allocated the per-face-segment +media-state array as `new string[n]`, leaving every element **null**, while +its single-face sibling `_faceMediaState` was correctly seeded to `""` +(DirectState). `NextMediaState` returns `current` UNCHANGED on three of its +four arms — including "the committed state is authored but its media array is +empty", which is retail's own keep-playing-the-previous-media rule. So on a +multi-segment button whose committed state carries no media, the null +survived the first `SyncMediaStates` and reached +`ElementInfo.StateMedia.TryGetValue(null)`, throwing mid-paint and taking the +process down. + +**Fix:** `Array.Fill(_segmentMediaStates, "")` at construction — the segment +array now starts on base media exactly like `_faceMediaState`, which is what +the surrounding comment already claimed the media machine did. + +**Regression test:** +`UiButtonTests.MultiSegmentFace_CommittedStateWithoutMedia_DrawsInsteadOfThrowing`. +Verified by reverting the one-line fix: the test throws `ArgumentNullException` +from `UiButton.ActiveFile`, the same frame as the live crash. + +**Note for whoever tidies this file:** the crash was found while +investigating Campaign LU item 4 ("launching the selected character doesn't +work"). It is why nothing worked — the client reached character select and +died there. Distinct from the LU5 UX work. + ## #419 — Portal-tunnel rim polygon visible (FOV-coupled) + ring flash at exit (camera dolly vs retail's view-plane animation) **Status:** OPEN (filed 2026-08-17, user screenshot + FOV experiment). diff --git a/docs/plans/2026-08-19-launcher-usability-campaign.md b/docs/plans/2026-08-19-launcher-usability-campaign.md new file mode 100644 index 00000000..c55026e3 --- /dev/null +++ b/docs/plans/2026-08-19-launcher-usability-campaign.md @@ -0,0 +1,262 @@ +# Campaign LU — launcher usability + +**Status:** PLANNED 2026-08-19. Not started; awaiting the goal being set. + +**Goal** + +> The launcher opens without a long wait. On startup it asks whether to +> update the launcher or the client, and restarts itself after a launcher +> update; the old update flow is gone. First-run setup ends with a success +> popup that returns you to the launcher on OK. A selected character +> launches directly. The sessions frame shows account, character (or Char +> Select) and whether they are in game — not the launch mode. + +**Why now.** Campaign LA shipped a launcher that is *correct* — atomic +installs, verified artifacts, session barriers, rollback — and *not +usable*. The user's verdict, twice: "way too complex", "too complex for +sending it to my friends". This campaign changes the surface a person +touches. It does not weaken what happens underneath. + +**Acceptance for the whole campaign** is the user's own walkthrough: +download `launcher-win-x64.zip` from the `latest` release, unzip, run, +install, play — without being told anything. + +--- + +## LU1 — the launcher opens immediately + +**Measured problem.** [App.axaml.cs:57](../../src/AcDream.Launcher/App.axaml.cs) +blocks the UI thread on `installer.LoadExistingAsync().GetAwaiter().GetResult()` +before the window is constructed. That reaches +`LauncherInstallRecordStore.VerifyFileAsync`, which computes a full SHA-256 +of the installed package. + +Measured on the user's machine 2026-08-19: + +| fact | value | +|---|---| +| `%LOCALAPPDATA%\acdream\pak\acdream.pak` | 29,908,271,024 bytes (27.9 GiB) | +| full SHA-256 | **24.1 s** at 1.16 GB/s | +| digest vs `install.json` | identical (`fee8595d…`) | + +So the startup cost is 24 s of disk read to re-confirm something that was +already true. A friend does not see it only because they have no package +installed yet — verification short-circuits at "nothing installed". It +will hit them the moment first-run setup finishes. + +**Change.** Startup verification becomes size + last-write-time against +the record. The full hash keeps running where it is cheap and meaningful: +at install, after an update installs a new package, and behind an explicit +**Verify files** button (the Steam shape). + +The cheap facts live in a **sidecar** (`install.verification.json`), not as a +new field on the install record. `LauncherInstallRecordStore` reads +`install.json` with `JsonUnmappedMemberHandling.Disallow`, so a new field +there would make an *older* launcher build reject the record outright and +demand a 28 GB re-bake after a rollback. An unknown sidecar file is simply +ignored by older builds, so the change is compatible in both directions. +An install with no sidecar yet pays one full hash and then writes it. + +**Acceptance** + +- Window visible in under 2 s with the 27.9 GiB package installed. +- Truncating or touching the package still blocks launch with a clear reason. +- **Verify files** reproduces the full check and reports pass/fail. +- The install and update paths still hash in full — unchanged. + +--- + +## LU2 — one update question, asked once, at startup + +**Change.** On start the launcher checks the feed once. If the launcher or +the client is behind, it shows **one** dialog naming what is out of date and +offering **Update** / **Not now**. Nothing else. + +- Launcher first when the feed's `minimumLauncherVersion` demands it, or + when only the launcher is behind: install, then **restart into the new + version** (`LauncherSelfUpdateBootstrap` already owns this handoff). +- Client otherwise: install, close the dialog, back at the launcher. +- Nothing to do: no dialog at all. The launcher just opens. + +**Acceptance** — three observed cases: up to date (silent), client behind +(one dialog → play), launcher behind (one dialog → relaunched on the new +version, confirmed by the version it reports). + +--- + +## LU3 — delete the old update surface + +The current prompt offers six buttons — Check again, Rollback client, +Stage launcher, Install client, Cancel, Close — plus a version table and a +restart-required banner. That is the flow being removed, along with the +"Check for updates" header button and the `LauncherUpdateViewModel` paths +only it reached. + +**What stays:** everything in `AcDream.Launcher.Core/Updates/` that makes +an update safe — manifest validation, bounded verified download, safe ZIP +extraction, versioned install with an atomic `current.json` switch, the +session barrier, and rollback as a *capability*. The complexity the user +objects to is the panel, not the safety beneath it. + +**Open — needs one confirmation before code is deleted:** rollback has no +place in the new single-question flow. It can move behind a small +"Advanced" affordance or leave the UI entirely (staying available as Core +API + tests). I will show the exact deletion list and ask before removing +it. + +**Acceptance** — exactly one update entry point in the UI; tests covering +deleted view-model behavior are removed with the code, never skipped. + +--- + +## LU4 — "Setup complete" ends first-run setup + +**Change.** When the bake publishes and the install record verifies, the +wizard shows a modal: setup succeeded, what was built, **OK**. OK closes +the wizard and returns to the launcher with the "Client setup required" +banner gone and launching enabled. + +**Acceptance** — a real first-run bake shows it exactly once on success; +cancellation and failure paths keep their existing error/status reporting +and must **not** show it. + +--- + +## LU5 — pressing Play on a character launches that character + +**Reproduce before changing anything.** The plumbing already exists end to +end: `LauncherOrchestrator.LaunchAsync` clones the character with the +*requested* mode (`CloneCharacter(character, mode)`), +`SessionConfigComposer.BuildSelector` emits an id selector (falling back to +name), and `RuntimeOptions.MapCharacterSelector` maps it into the App host. +A defect somewhere in a chain that reads correct is exactly the case this +project has repeatedly lost time to by guessing. + +Two candidates to separate by observation, not argument: + +1. The launch button is gated off by a capability reason, so the click + never becomes a session. +2. The selector reaches the client but the roster match fails, so character + select stays on screen — which is what "you can just select different + chars" describes. + +**Change.** One obvious **Play** per character that enters the world as +that character, plus the deliberate "Character select" path kept separate. +Three near-identical launch buttons is itself part of the complaint. + +**Acceptance** — select a character, press Play, arrive in the world as +that character with no character-select screen in between. + +--- + +## LU6 — the sessions frame says who is playing + +Today each row reads `server / account / character`, then `Mode` +(Gui/GuiSelect/Headless/Probe), then `State`, then a raw status string. +The launch mode is launcher bookkeeping and means nothing to a player. + +**Change.** Each row shows the account, the character — or **Character +select** when no character was chosen — and one plain status word derived +from the host's own status stream: + +`Starting` → `Character select` → `In game` → `Stopped` / `Failed` + +Errors keep their own line. Stop keeps its button. Character-refresh +(probe) rows stay distinguishable from play sessions. + +**Acceptance** — launching a character shows account + name + **In game** +once in world; a character-select launch shows **Character select** until a +character is entered. + +--- + +## Non-goals + +- No change to download verification, atomic install, or the session barrier. +- No change to credential handling (plaintext profile remains the user's decision). +- No change to Linux graphical gating (Slice L stays parked). + +## Working rules for this campaign + +- One slice per commit, `dotnet build` + `dotnet test` green before each. +- Push to main; CI gates on both runners and publishes the release the + launcher itself updates from — so every slice is testable by the user + through the shipped path within a few minutes. +- LU3's deletions and LU5's root cause get shown to the user before they + land. + +--- + +# Implementation notes (recon 2026-08-19, before any code) + +These were read out of the tree, not assumed. They exist so each slice +starts from the mechanism that is already there instead of re-deriving it. + +## The self-update restart chain already exists end to end (LU2) + +`LauncherUpdater.StageLauncherAsync` stages a verified payload and writes a +plan. On the next ordinary startup `LauncherSelfUpdateBootstrap.HandleAsync` +takes the exclusive lease, sees `SelfUpdatePlanState.Staged`, and spawns the +STAGED launcher in helper mode. `RunHelperAsync` waits for the parent PID to +exit, applies the replacement, starts the updated launcher with +`--acdream-self-update-confirm-v1`, and waits for the confirmation receipt. + +So "restart after a launcher update" needs no new update machinery. What it +needs is one seam: after staging succeeds, start the staged helper against +the CURRENT process and shut down. Extract the existing staged-plan branch of +`HandleAsync` into a callable entry point and reuse it — do not duplicate it, +and do not restart by launching a second copy of the launcher and hoping the +bootstrap picks the plan up, which races the exclusive lease against the +process that is still shutting down. + +## The orchestrator already knows "in game" (LU6) + +`LauncherActivityState` has `InWorld`, and the orchestrator already sets it +from `EnteredWorldStatusEvent`, which carries the real `CharacterId` and +`CharacterName` from the host. Today that identity is written into a status +STRING (`"In world as X."`) and thrown away. + +LU6 promotes it: the entered-world event updates the activity's character +name so a character-select launch can show who is actually being played, and +the row renders one word derived from `LauncherActivityState` rather than the +raw enum plus the launch mode: + +| state | row shows | +|---|---| +| `Starting`, `Running` | Starting | +| `Connected` | Character select | +| `InWorld` | In game | +| `Disconnected`, `Stopping` | Stopping | +| `Exited`, `Cancelled` | Stopped | +| `Failed` | Failed | + +`LauncherActivityKind.Probe` rows stay visually distinct (they are a +character refresh, not a play session). + +## First-run completion has an exact point (LU4) + +`FirstRunInstallerViewModel.StartAsync` succeeds at the line that calls +`_onInstalled(result.Record)` and sets `Phase = LauncherInstallPhase.Completed`. +That is where the success dialog belongs — after the record is published, so +the launcher behind it is already in its launch-enabled state when the user +presses OK. The cancelled and failed branches immediately below it must not +reach it. + +## The launcher side of "launch this character" reads correct (LU5) + +Confirmed by reading, so the live repro can skip re-checking these: + +- `LauncherOrchestrator.LaunchAsync` -> `CloneCharacter(character, mode)` + overrides the profile's saved `LaunchMode` with the mode the button asked + for, so the stored default cannot leak into an explicit launch. +- `SessionConfigComposer.Compose` builds a selector for every mode except + `GuiSelect`, preferring a parsed non-zero id over the name. +- `SessionPlayerComposition` passes the selector into + `LiveSessionConnectOptions` with `AwaitCharacterSelection: selector is null`, + and `InteractionRetainedUiComposition` binds the character-selection UI only + when the selector is null. + +The user's stored profiles all carry `launchMode: "guiSelect"` (the default), +and every cached character has a real id. So the defect is NOT a missing id +and NOT the saved default overriding the click. Reproduce live before +changing anything. diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index bdba5f1d..c5fd0d21 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -441,6 +441,19 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful // state at construction, exactly retail's Initialize -> SetState // ordering. _segmentMediaStates = new string[_faceSegments.Length]; + // #420: seed every segment with DirectState (""), exactly like + // _faceMediaState's own initializer above. `new string[n]` leaves + // nulls, and NextMediaState returns `current` UNCHANGED on three of + // its four arms (committed state authored with an empty media array, + // or no committed/base state and no "" entry) — so on a multi-segment + // button whose committed state carries no media the null survived the + // first SyncMediaStates and reached + // ElementInfo.StateMedia.TryGetValue(null), throwing + // ArgumentNullException ("Parameter 'key'") from inside OnDraw. + // Observed live: it killed the client mid-paint on the character- + // select screen on every launch (session status.jsonl: connected -> + // characterList -> exited code 1 "crashed"). + Array.Fill(_segmentMediaStates, ""); _resolve = resolve; ClickThrough = false; // buttons are interactive — opt OUT of click-through diff --git a/tests/AcDream.App.Tests/UI/UiButtonTests.cs b/tests/AcDream.App.Tests/UI/UiButtonTests.cs index f124991f..aba80c27 100644 --- a/tests/AcDream.App.Tests/UI/UiButtonTests.cs +++ b/tests/AcDream.App.Tests/UI/UiButtonTests.cs @@ -745,6 +745,35 @@ public class UiButtonTests private static UiButton CreateButton(ElementInfo info) => new(info, NoTex) { Width = info.Width, Height = info.Height }; + /// + /// #420 regression. A multi-segment face whose committed state authors no + /// media used to leave that segment's media-state name NULL (the array + /// started as new string[n] and NextMediaState returns the previous + /// value unchanged on that arm), and the null then reached + /// ElementInfo.StateMedia.TryGetValue, throwing + /// ArgumentNullException from inside OnDraw. Live symptom: the client + /// crashed on the character-select screen on every launch. + /// + /// The assertion is secondary — the point is that drawing COMPLETES. + /// Before the fix this test throws instead of failing. + /// + [Fact] + public void MultiSegmentFace_CommittedStateWithoutMedia_DrawsInsteadOfThrowing() + { + var info = new ElementInfo { Type = 1, Width = 32, Height = 16 }; + var button = new UiButton( + info, + static file => (file, 8, 8), + mediaInfo: null, + faceSegments: [new ElementInfo { Type = 1, Width = 16, Height = 16 }]) + { + Width = info.Width, + Height = info.Height, + }; + + Assert.Equal(0u, DrawnFaceFile(button)); + } + // ── #416 media-rule draw harness ───────────────────────────────────── private sealed class NullGpuFrameSource From 00d1278228808b553c25683c9afd512563e939c4 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 18:40:34 +0200 Subject: [PATCH 031/263] =?UTF-8?q?feat(launcher):=20LU1=20=E2=80=94=20sto?= =?UTF-8?q?p=20hashing=2028=20GB=20before=20the=20launcher=20window=20appe?= =?UTF-8?q?ars?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on the user's machine: %LOCALAPPDATA%\acdream\pak\acdream.pak is 29,908,271,024 bytes and SHA-256 over it takes 24.1 s at 1.16 GB/s. App .OnFrameworkInitializationCompleted ran exactly that hash synchronously, before constructing the window, and the digest came back identical to the one install.json already recorded. So the launcher took roughly half a minute to appear in order to re-confirm a fact that had not changed. A friend does not see it only because they have no package installed yet — verification short-circuits at "nothing installed" — so it would hit them the moment first-run setup finished. Startup now checks the cheap facts (size, last-write time) and skips only the hash, and only when a previous FULL hash of that same file agreed with the install record. Everything that should hash still does: install, update, the crash-recovery backup path, and a new explicit "Verify files" button. The remembered fact lives in a SIDECAR (install.verification.json), not as a new field on the install record: LauncherInstallRecordStore reads install.json with JsonUnmappedMemberHandling.Disallow, so a new property there would make an older launcher build reject the record outright and demand a fresh ~28 GB bake after a rollback. An unknown sidecar is simply ignored by builds that predate it. The cache type never throws — it sits in front of a guarantee, so every failure mode (missing, corrupt, unknown schema, unwritable) degrades to "hash it again" rather than to a failed launch. Two subtleties worth keeping: - The write time is re-read after the hash and the entry is only written when it is unchanged. A writer racing a multi-second hash would otherwise be remembered under the OLD timestamp, and the next startup would trust a digest that never covered those bytes. - A hash that disagrees with the record invalidates the entry, so a stale "verified" fact cannot outlive the evidence that produced it. Tests: PreparedAssetVerificationCacheTests (10) counts hash invocations through the store's injectable hasher and covers second-startup skip, forced full verification, touched package, same-size silent corruption, resize, a cache digest that disagrees with the record, three unreadable-cache shapes, and backup recovery still hashing. Plus two LauncherWindowViewModel tests for the Verify files command. Launcher.Core 335 passed, Launcher 57 passed. Note for the first run after this ships: the very first startup still pays one full hash to learn the digest for the installed file, and every startup after that is instant. Campaign LU slice LU1. Plan: docs/plans/2026-08-19-launcher-usability-campaign.md Co-Authored-By: Claude Opus 5 --- .../LauncherInstallRecordStore.cs | 79 ++++++- .../Installation/LauncherInstaller.cs | 29 ++- .../PreparedAssetVerificationCache.cs | 189 +++++++++++++++ src/AcDream.Launcher/MainWindow.axaml | 3 + .../ViewModels/FirstRunInstallerViewModel.cs | 3 +- .../ViewModels/LauncherWindowViewModel.cs | 64 ++++- .../PreparedAssetVerificationCacheTests.cs | 223 ++++++++++++++++++ .../LauncherWindowViewModelTests.cs | 97 +++++++- 8 files changed, 665 insertions(+), 22 deletions(-) create mode 100644 src/AcDream.Launcher.Core/Installation/PreparedAssetVerificationCache.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Installation/PreparedAssetVerificationCacheTests.cs diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs index f88dba2f..d2843964 100644 --- a/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs +++ b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs @@ -41,6 +41,7 @@ public sealed class LauncherInstallRecordStore private readonly ApplicationPathSet _paths; private readonly DatDirectoryLocator _datDirectories; private readonly Func> _computeSha256; + private readonly PreparedAssetVerificationCache _verificationCache; public LauncherInstallRecordStore( ApplicationPathSet paths, @@ -52,6 +53,7 @@ public sealed class LauncherInstallRecordStore _computeSha256 = computeSha256 ?? ((path, cancellationToken) => FileIntegrity.ComputeSha256HexAsync(path, cancellationToken)); + _verificationCache = new PreparedAssetVerificationCache(DataDirectory); } public string DataDirectory => Path.GetFullPath(_paths.DataDirectory); @@ -66,20 +68,28 @@ public sealed class LauncherInstallRecordStore public static string GetBackupPath(string preparedAssetPath) => preparedAssetPath + ".previous-install"; + /// Hash the package even when a + /// previous full hash of the same bytes is remembered. Install, update, + /// and any explicit "verify my files" request pass true; ordinary startup + /// passes false. public async Task LoadAndVerifyAsync( - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + bool forceFullVerification = false) { await using InstallerTransactionLease lease = await InstallerTransactionLease.AcquireAsync( DataDirectory, cancellationToken) .ConfigureAwait(false); - return await LoadAndVerifyUnderLeaseAsync(cancellationToken) + return await LoadAndVerifyUnderLeaseAsync( + cancellationToken, + forceFullVerification) .ConfigureAwait(false); } internal async Task LoadAndVerifyUnderLeaseAsync( - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + bool forceFullVerification = false) { if (!File.Exists(RecordPath)) { @@ -143,7 +153,8 @@ public sealed class LauncherInstallRecordStore FileVerification current = await VerifyFileAsync( record.PreparedAssetPath, record, - cancellationToken) + cancellationToken, + allowCachedResult: !forceFullVerification) .ConfigureAwait(false); if (current.IsValid) { @@ -154,10 +165,15 @@ public sealed class LauncherInstallRecordStore // A process crash may occur after the old verified package was moved // aside but before the replacement record was published. Verify the // backup against the still-current record before restoring it. + // The backup is a RECOVERY path: it runs only because the live package + // just failed, and it decides whether to move a file into that + // package's place. It always hashes — a cache entry describes the live + // package, never this one. FileVerification backup = await VerifyFileAsync( backupPath, record, - cancellationToken) + cancellationToken, + allowCachedResult: false) .ConfigureAwait(false); if (backup.IsValid) { @@ -387,10 +403,23 @@ public sealed class LauncherInstallRecordStore }; } + /// + /// When true, a matching entry + /// stands in for the hash. Hashing the prepared package is proportional to + /// its size, and it is very large — 27.9 GiB and 24.1 s on the machine this + /// was measured on — so doing it on every ordinary startup made the + /// launcher take half a minute to appear while re-confirming a fact that + /// had not changed. The cheap facts (size, last-write time) still run + /// unconditionally; only the hash is skipped, and only when a previous full + /// hash of that same file agreed with this record. + /// Pass false for the install/update paths and for any explicit + /// verify-my-files request, which must always hash. + /// private async Task VerifyFileAsync( string path, LauncherInstallRecord record, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool allowCachedResult) { if (!File.Exists(path)) { @@ -399,7 +428,8 @@ public sealed class LauncherInstallRecordStore try { - long length = new FileInfo(path).Length; + var file = new FileInfo(path); + long length = file.Length; if (length != record.PreparedAssetSize) { return new FileVerification( @@ -408,13 +438,42 @@ public sealed class LauncherInstallRecordStore + $"{record.PreparedAssetSize}, found {length})."); } + DateTime lastWriteUtc = file.LastWriteTimeUtc; + if (allowCachedResult + && PreparedAssetVerificationCache.Satisfies( + _verificationCache.TryRead(), + path, + length, + lastWriteUtc, + record.PreparedAssetSha256)) + { + return new FileVerification(true, "Client content verified."); + } + string sha256 = await _computeSha256(path, cancellationToken) .ConfigureAwait(false); - return FileIntegrity.Matches(sha256, record.PreparedAssetSha256) - ? new FileVerification(true, "Client content verified.") - : new FileVerification( + if (!FileIntegrity.Matches(sha256, record.PreparedAssetSha256)) + { + // The remembered fact, if any, is now known to be wrong about + // this file. Drop it rather than leaving a stale "verified" + // entry that a later startup could believe. + _verificationCache.Invalidate(); + return new FileVerification( false, "The prepared package SHA-256 does not match the install record."); + } + + // Re-read the write time: a concurrent writer between the stat + // above and the end of a multi-second hash would otherwise be + // remembered under the OLD timestamp, and the next startup would + // trust the cache for a file the hash never actually covered. + DateTime hashedWriteUtc = new FileInfo(path).LastWriteTimeUtc; + if (hashedWriteUtc == lastWriteUtc) + { + _verificationCache.Write(path, length, lastWriteUtc, sha256); + } + + return new FileVerification(true, "Client content verified."); } catch (OperationCanceledException) { diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs index 4c8d4d0d..4deae741 100644 --- a/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs +++ b/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs @@ -52,8 +52,14 @@ public interface ILauncherInstaller DatDirectoryValidation ValidateDatDirectory(string? directory); + /// Hash the installed package even + /// when a previous full hash of the same bytes is remembered. Ordinary + /// startup passes false so the launcher window is not held behind a + /// multi-second hash of a very large file; an explicit "verify my files" + /// request passes true. Task LoadExistingAsync( - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default, + bool forceFullVerification = false); Task InstallAsync( string datDirectory, @@ -115,7 +121,8 @@ public sealed class LauncherInstaller : ILauncherInstaller _datDirectories.Validate(directory); public async Task LoadExistingAsync( - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + bool forceFullVerification = false) { await _installGate.WaitAsync(cancellationToken).ConfigureAwait(false); try @@ -128,7 +135,8 @@ public sealed class LauncherInstaller : ILauncherInstaller .ConfigureAwait(false); InstallRecordVerification verification = await RecoverExistingUnderPublicationGuardAsync( - cancellationToken) + cancellationToken, + forceFullVerification) .ConfigureAwait(false); _verifiedRecord = verification.Record; return verification; @@ -204,7 +212,13 @@ public sealed class LauncherInstaller : ILauncherInstaller string outputPath = _recordStore.PreparedAssetPath; string backupPath = LauncherInstallRecordStore.GetBackupPath(outputPath); InstallRecordVerification existing = - await RecoverExistingUnderPublicationGuardAsync(cancellationToken) + await RecoverExistingUnderPublicationGuardAsync( + cancellationToken, + // An install is about to replace this package, and this + // call decides whether the PRIOR one can be recovered. + // That decision must rest on real bytes, never on a + // remembered digest. + forceFullVerification: true) .ConfigureAwait(false); _verifiedRecord = existing.Record; @@ -441,7 +455,8 @@ public sealed class LauncherInstaller : ILauncherInstaller private async Task RecoverExistingUnderPublicationGuardAsync( - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool forceFullVerification = false) { string outputPath = _recordStore.PreparedAssetPath; await using BakePublicationGuardContract.PublicationLease publication = @@ -455,7 +470,9 @@ public sealed class LauncherInstaller : ILauncherInstaller // promotion before recovery reaches this invalidation point. BakePublicationGuardContract.Invalidate(outputPath, publication); BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath); - return await _recordStore.LoadAndVerifyUnderLeaseAsync(cancellationToken) + return await _recordStore.LoadAndVerifyUnderLeaseAsync( + cancellationToken, + forceFullVerification) .ConfigureAwait(false); } diff --git a/src/AcDream.Launcher.Core/Installation/PreparedAssetVerificationCache.cs b/src/AcDream.Launcher.Core/Installation/PreparedAssetVerificationCache.cs new file mode 100644 index 00000000..1ca28178 --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/PreparedAssetVerificationCache.cs @@ -0,0 +1,189 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AcDream.Launcher.Core.Installation; + +/// +/// One remembered "this exact file already hashed to this digest" fact. +/// is stored so the entry can be matched against the +/// install record's own digest — an entry that agrees with the file on disk +/// but disagrees with the record must never satisfy verification. +/// +internal sealed record PreparedAssetVerificationEntry +{ + public required int Version { get; init; } + + public required string Path { get; init; } + + public required long Size { get; init; } + + public required long LastWriteUtcTicks { get; init; } + + public required string Sha256 { get; init; } +} + +/// +/// Remembers the result of a full package hash so ordinary startup does not +/// have to repeat it. +/// +/// Why this is a sidecar and not a field on the install record. +/// reads install.json with +/// , so adding a property +/// there would make an OLDER launcher build reject the record outright and +/// demand a fresh ~28 GB bake after a rollback. An unknown sidecar file is +/// simply ignored by builds that predate it, which keeps the change +/// compatible in both directions — and a launcher that ignores the cache +/// merely hashes, which is the behavior that existed before. +/// +/// This type never throws. It is an optimization sitting in +/// front of a guarantee; a cache that could fail would turn a missing or +/// corrupt scratch file into a failed launch. Reads return null on anything +/// unexpected and writes swallow IO failures, so every failure mode +/// degrades to "hash it again". +/// +internal sealed class PreparedAssetVerificationCache +{ + internal const int CurrentVersion = 1; + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + }; + + private readonly string _path; + + public PreparedAssetVerificationCache(string dataDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory); + _path = System.IO.Path.Combine( + System.IO.Path.GetFullPath(dataDirectory), + "install.verification.json"); + } + + public string CachePath => _path; + + /// + /// The remembered entry, or null when there is none, it cannot be read, + /// it was written by a schema this build does not know, or it is + /// internally incomplete. + /// + public PreparedAssetVerificationEntry? TryRead() + { + try + { + if (!File.Exists(_path)) + { + return null; + } + + using FileStream stream = File.OpenRead(_path); + PreparedAssetVerificationEntry? entry = + JsonSerializer.Deserialize( + stream, + SerializerOptions); + if (entry is null + || entry.Version != CurrentVersion + || string.IsNullOrWhiteSpace(entry.Path) + || string.IsNullOrWhiteSpace(entry.Sha256) + || entry.Size <= 0) + { + return null; + } + + return entry; + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or JsonException + or NotSupportedException + or ArgumentException) + { + return null; + } + } + + /// + /// Records that , at this size and write time, + /// hashed to . Written through a temporary file + /// so an interrupted write cannot leave a half-parsed entry behind. + /// + public void Write(string path, long size, DateTime lastWriteUtc, string sha256) + { + var entry = new PreparedAssetVerificationEntry + { + Version = CurrentVersion, + Path = System.IO.Path.GetFullPath(path), + Size = size, + LastWriteUtcTicks = lastWriteUtc.Ticks, + Sha256 = sha256, + }; + + string temporaryPath = _path + ".tmp"; + try + { + string? directory = System.IO.Path.GetDirectoryName(_path); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllText( + temporaryPath, + JsonSerializer.Serialize(entry, SerializerOptions)); + File.Move(temporaryPath, _path, overwrite: true); + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or NotSupportedException + or ArgumentException) + { + TryDelete(temporaryPath); + } + } + + /// Forgets the remembered entry. Called when a full hash + /// disagrees with the install record, so a stale "verified" fact can + /// never outlive the evidence that produced it. + public void Invalidate() => TryDelete(_path); + + /// + /// True when this entry can stand in for a full hash of the file + /// currently on disk: same file, same size, same write time, and a digest + /// that still agrees with the install record. + /// + public static bool Satisfies( + PreparedAssetVerificationEntry? entry, + string path, + long size, + DateTime lastWriteUtc, + string recordedSha256) => + entry is not null + && string.Equals( + entry.Path, + System.IO.Path.GetFullPath(path), + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal) + && entry.Size == size + && entry.LastWriteUtcTicks == lastWriteUtc.Ticks + && Integrity.FileIntegrity.Matches(entry.Sha256, recordedSha256); + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or NotSupportedException + or ArgumentException) + { + // Best effort by contract — see the type doc. + } + } +} diff --git a/src/AcDream.Launcher/MainWindow.axaml b/src/AcDream.Launcher/MainWindow.axaml index 2f0a8515..9580ac42 100644 --- a/src/AcDream.Launcher/MainWindow.axaml +++ b/src/AcDream.Launcher/MainWindow.axaml @@ -44,6 +44,9 @@