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'