acdream/docs/research/2026-08-14-campaign-la-test-script.md

31 KiB
Raw Permalink Blame History

Campaign LA11 — automated preflight and connected user gate

Status: implementation checkpoint only. Run this script after the reviewed LA10/LA11 commits are integrated and the campaign branch is clean. Campaign LA, the Linux graphical client, and issue #397 remain open until the user records a verdict for every applicable row below.

This is the single Campaign LA operator script. The automated section is display-free and connection-free. Rows AI are deliberately manual and serial: they use real retail DATs, a local ACE server, user-entered credentials, and visual judgment that automation cannot supply.

1. Safety boundary and required inputs

Use placeholders throughout; never paste a password into a terminal, this document, a screenshot, or a gate report.

  • <ABSOLUTE_REPOSITORY_ROOT>: a clean Campaign LA worktree at the exact commit under test.
  • <ABSOLUTE_RETAIL_DAT_DIRECTORY>: a read-only source containing client_portal.dat, client_cell_1.dat, client_highres.dat, and client_local_English.dat.
  • <ACE_PORT>, <LA11_SERVER>, and <LA11_ACCOUNT>: a local ACE endpoint and account. Enter the account password only in the launcher's masked Password field. The launcher intentionally stores it as plaintext in the isolated launcher-profiles.json; children receive it through redirected stdin.
  • <OBSERVER_CHARACTER>: a second user-controlled character that can observe a private /tell from each play mode.
  • <DISPOSABLE_CHARACTER>: a server-operator-approved disposable character. Never substitute a primary character. If none exists, provision one with the local server's normal admin procedure before row G.
  • Windows 11 x64, PowerShell 7, .NET 10 SDK, a local ACE server, and a supported Vulkan Windows machine for rows AH. Ubuntu x64 with PowerShell 7 and a Linux desktop/WSLg is required for row I. The Avalonia launcher is supported on Linux; gui and guiSelect client actions must remain disabled with the Modern Runtime Slice-L explanation.

Close every unrelated AcDream.App, acdream-headless, and acdream launcher before starting. Do not run another acdream gate in parallel. All generated files must stay below one new gate directory; the canonical %APPDATA%, %LOCALAPPDATA%, and XDG acdream roots are out of scope.

2. Automated preflight — no UI, connection, credential, or bake

In PowerShell 7 on Windows:

$Repo = [IO.Path]::GetFullPath('<ABSOLUTE_REPOSITORY_ROOT>')
$Stamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss')
$Gate = Join-Path $Repo "logs/campaign-la-user-gate-$Stamp"
$Preflight = Join-Path $Gate 'automated-preflight'
New-Item -ItemType Directory -Path $Gate | Out-Null

pwsh -NoProfile -File (Join-Path $Repo 'tools/run-campaign-la-preflight.ps1') `
  -Repository $Repo `
  -AllowedOutputRoot $Gate `
  -OutputDirectory $Preflight

$Report = Get-Content -LiteralPath (Join-Path $Preflight 'report.json') -Raw |
  ConvertFrom-Json
if (-not $Report.success -or $Report.dirty) {
    throw 'Stop: automated preflight failed or recorded a dirty worktree.'
}
if ($Report.head -cne (git -C $Repo rev-parse HEAD).Trim()) {
    throw 'Stop: preflight HEAD does not equal the current HEAD.'
}

The expected matrix is:

Platform Automated command group Required result Typical time
Windows Release AcDream.slnx build, -m:1 exit 0 515 min
Windows complete Release solution test, serial exit 0; ordinary known skips only 2060 min
Windows focused Launcher.Core update tests and launcher update/startup-option tests exit 0 14 min
Windows canonical portable project build/test closure plus Headless --help and empty-config validate from headless-portability.yml every project/CLI row exits 0 1025 min
Windows self-contained single-file launcher publish for win-x64 and linux-x64 launcher + bake roots present, no root DLL fallback 310 min
Windows native launcher --verify-publish and bake --help with bogus DOTNET_ROOT* both exit 0 <1 min
Ubuntu/WSL run the same helper natively from the Linux path to the worktree Linux RID report and every row exit 0 3590 min

report.json records the tested HEAD/dirty state, OS/RID, exact commands, durations, exits, redacted logs, and SHA-256/size inventory. A normal preflight plans 32 rows, including the connection-free PID/status/redaction and script- safety contract suites. -AllowedOutputRoot must be a fresh, explicit campaign-la-* gate root (or the repository logs root), and output must be a fresh, empty, non-reparse strict descendant; repository, home, source, payload, nonempty, and arbitrary existing directories are rejected. The helper never launches App or Headless in connected mode and never reads a credential. Every child starts with all inherited ACDREAM_* variables removed, so a developer shell cannot accidentally enable live, installed-DAT, fixture-regeneration, or diagnostic gates. Only the optional row below adds the two named DAT variables back for its three exact tests.

Optional installed-DAT read-only row

This is not a bake and must not replace row A. Add the switches below only when the DAT directory may be read by tests:

pwsh -NoProfile -File (Join-Path $Repo 'tools/run-campaign-la-preflight.ps1') `
  -Repository $Repo `
  -AllowedOutputRoot $Gate `
  -OutputDirectory (Join-Path $Gate 'automated-preflight-with-dat') `
  -IncludeInstalledDat `
  -InstalledDatDirectory '<ABSOLUTE_RETAIL_DAT_DIRECTORY>'

The mandatory installed-DAT result is CharacterManagementLiveDatTests with both ACDREAM_PROBE_LIVE_MOUNT=1 and ACDREAM_DAT_DIR set inside the child environment. The helper reads the TRX and fails if the test skipped or did anything other than pass. The action-map and portal-asset probes are additional coverage, never substitutes. Expected matrix size: 36 rows.

On Ubuntu/WSL, invoke the same script with native pwsh, a Linux repository path, and a Linux output path. Do not treat a Windows-hosted run over wsl.exe as the Linux row.

3. Prepare the deterministic local A/B feed

Build distinct, version-stamped payloads so the staged launcher really changes from A to B. These commands write only below $Gate (normal project obj/bin incremental outputs are the already-authorized build outputs):

$VersionA = '1.0.1-la11.a'
$VersionB = '1.0.1-la11.b'
$Payloads = Join-Path $Gate 'update-payloads'
$Fixture = Join-Path $Gate 'update-fixture'

function Publish-LaRelease([string]$Version, [string]$Label) {
    $ClientWin = Join-Path $Payloads "$Label/client-win-x64"
    $LauncherWin = Join-Path $Payloads "$Label/launcher-win-x64"
    $ClientLinux = Join-Path $Payloads "$Label/client-linux-x64"
    $LauncherLinux = Join-Path $Payloads "$Label/launcher-linux-x64"

    dotnet publish (Join-Path $Repo 'src/AcDream.App/AcDream.App.csproj') `
      -c Release -r win-x64 --self-contained true -p:Version=$Version `
      -o $ClientWin --nologo
    if ($LASTEXITCODE) { throw "App win-x64 publish failed: $Label" }
    dotnet publish (Join-Path $Repo 'src/AcDream.Headless/AcDream.Headless.csproj') `
      -c Release -r win-x64 --self-contained true -p:Version=$Version `
      -o $ClientWin --nologo
    if ($LASTEXITCODE) { throw "Headless win-x64 publish failed: $Label" }
    dotnet publish (Join-Path $Repo 'src/AcDream.Launcher/AcDream.Launcher.csproj') `
      -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true `
      -p:Version=$Version -o $LauncherWin --nologo
    if ($LASTEXITCODE) { throw "Launcher win-x64 publish failed: $Label" }

    dotnet publish (Join-Path $Repo 'src/AcDream.App/AcDream.App.csproj') `
      -c Release -r linux-x64 --self-contained true -p:Version=$Version `
      -o $ClientLinux --nologo
    if ($LASTEXITCODE) { throw "App linux-x64 publish failed: $Label" }
    dotnet publish (Join-Path $Repo 'src/AcDream.Headless/AcDream.Headless.csproj') `
      -c Release -r linux-x64 --self-contained true -p:Version=$Version `
      -o $ClientLinux --nologo
    if ($LASTEXITCODE) { throw "Headless linux-x64 publish failed: $Label" }
    dotnet publish (Join-Path $Repo 'src/AcDream.Launcher/AcDream.Launcher.csproj') `
      -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true `
      -p:Version=$Version -o $LauncherLinux --nologo
    if ($LASTEXITCODE) { throw "Launcher linux-x64 publish failed: $Label" }
}

Publish-LaRelease $VersionA 'A'
Publish-LaRelease $VersionB 'B'

pwsh -NoProfile -File (Join-Path $Repo 'tools/new-campaign-la-update-fixture.ps1') `
  -OutputDirectory $Fixture `
  -ClientWinX64DirectoryA (Join-Path $Payloads 'A/client-win-x64') `
  -LauncherWinX64DirectoryA (Join-Path $Payloads 'A/launcher-win-x64') `
  -ClientLinuxX64DirectoryA (Join-Path $Payloads 'A/client-linux-x64') `
  -LauncherLinuxX64DirectoryA (Join-Path $Payloads 'A/launcher-linux-x64') `
  -ClientWinX64DirectoryB (Join-Path $Payloads 'B/client-win-x64') `
  -LauncherWinX64DirectoryB (Join-Path $Payloads 'B/launcher-win-x64') `
  -ClientLinuxX64DirectoryB (Join-Path $Payloads 'B/client-linux-x64') `
  -LauncherLinuxX64DirectoryB (Join-Path $Payloads 'B/launcher-linux-x64')

The helper rejects nonempty output, invalid or non-monotonic versions, missing root executables (including the co-deployed Bake CLI), nonabsolute inputs, output/source overlap in either direction, and any reparse point in source or output ancestry. It enumerates normalized relative paths with ordinal ordering, never its own output, and normalizes ZIP origin to Unix on both hosts so Windows/Linux hashes are identical under multiple cultures while native Linux extraction retains 0755 for App/Headless/Launcher/Bake and 0644 for ordinary files. It writes fixed-timestamp sorted ZIPs, the exact LA10 v1 SHA/size manifest, fixture-report.json, a loopback-only server (with optional bounded -MaximumRequests smoke mode), and an atomic A/B selector. Both generated helpers reject a -Root other than their own fixture directory. The generator does not download or mutate payload sources.

Start the Windows loopback server without a shell or visible helper window:

$ServerInfo = [Diagnostics.ProcessStartInfo]::new()
$ServerInfo.FileName = (Get-Command pwsh).Source
$ServerInfo.UseShellExecute = $false
$ServerInfo.CreateNoWindow = $true
foreach ($Value in @(
    '-NoProfile', '-File', (Join-Path $Fixture 'serve-fixture.ps1'),
    '-Root', $Fixture, '-Port', '43119')) {
    $ServerInfo.ArgumentList.Add($Value)
}
$FixtureServer = [Diagnostics.Process]::Start($ServerInfo)
$ManifestUri = 'http://127.0.0.1:43119/manifest.json'
if ((Invoke-RestMethod -Uri $ManifestUri).version -cne $VersionA) {
    throw 'Stop: local fixture did not begin on release A.'
}

4. Windows isolated launcher command and evidence rule

$WinRoot = Join-Path $Gate 'windows-roots'
$WinConfig = Join-Path $WinRoot 'config'
$WinData = Join-Path $WinRoot 'data'
$WinCache = Join-Path $WinRoot 'cache'
$Evidence = Join-Path $Gate 'evidence'
New-Item -ItemType Directory -Path $Evidence | Out-Null

$LauncherA = Join-Path $Payloads 'A/launcher-win-x64/acdream-launcher.exe'
$LauncherArguments = @(
    '--config-dir', $WinConfig,
    '--data-dir', $WinData,
    '--cache-dir', $WinCache,
    '--update-manifest-uri', $ManifestUri)
& $LauncherA @LauncherArguments

All four options are process-local. The three roots are an indivisible set; the local feed reaches only the updater and is never persisted. A self-update must preserve the same validated suffix through helper and confirmation restarts. The launcher, profiles, installer, current-version store, updater, session composer, and orchestrator must all use this one exact path set.

For every play/probe row, start this gate-only PID watcher immediately before clicking Refresh/Play. It correlates only the unique isolated session-config path, records neither raw command line nor config contents, and must finish while the child is still live. Its safe sidecar contains the normalized config path, a sanitized command fingerprint, and PID plus an OS-native process-start identity so later PID reuse cannot become a false leak:

$CapturePath = Join-Path $Evidence '<ROW>-process.capture.json'
$CaptureStart = [DateTimeOffset]::UtcNow
$CaptureInfo = [Diagnostics.ProcessStartInfo]::new()
$CaptureInfo.FileName = (Get-Command pwsh).Source
$CaptureInfo.UseShellExecute = $false
$CaptureInfo.CreateNoWindow = $true
foreach ($Value in @(
    '-NoProfile', '-File', (Join-Path $Repo 'tools/capture-campaign-la-session-process.ps1'),
    '-SessionsDirectory', (Join-Path $WinCache 'launcher/sessions'),
    '-CreatedAfterUtc', $CaptureStart.ToString('O'),
    '-ReportPath', $CapturePath, '-WaitSeconds', '60')) {
    $CaptureInfo.ArgumentList.Add($Value)
}
$CaptureProcess = [Diagnostics.Process]::Start($CaptureInfo)
# Click exactly one Refresh/Play action now, then wait for capture.
$CaptureProcess.WaitForExit()
if ($CaptureProcess.ExitCode) { throw 'Stop: live child PID capture failed.' }
$Capture = Get-Content -LiteralPath $CapturePath -Raw | ConvertFrom-Json
$SessionConfig = Join-Path $WinCache "launcher/sessions/$($Capture.sessionId)/session.json"
$Status = Join-Path $WinCache "launcher/sessions/$($Capture.sessionId)/status.jsonl"

# After Stop and terminal status:
pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') `
  -StatusFile $Status `
  -Mode '<probe|guiSelect|gui|headless>' `
  -ProcessCapturePath $CapturePath `
  -CredentialProfilePath (Join-Path $WinConfig 'launcher-profiles.json') `
  -ExpectedSessionId $Capture.sessionId `
  -ReportPath (Join-Path $Evidence '<ROW>-status.validation.json')

Add -ExpectedPlugin acdream.smoke to rows DF. The validator enforces exact v1 fields and property order, one session id, UTC monotonic timestamps, mode-specific lifecycle order, exit code 0/reason, no unexpected plugin/login command failure, exact terminal disconnected.reason == stopped, credential redaction, and that exact captured process instance is gone. Independent exact config-path correlation uses Windows CIM or Linux /proc/*/cmdline; it never globally scans a process name, treats a different start identity on a reused PID as a different process, and is unaffected by unrelated same-name processes or Linux's 15-character names. The validator verifies owner-only profile access, reads only password/secret fields in memory, recursively checks every allowed status string (including command/error text), and reports only the forbidden-value count and status hash—never credential content or a credential hash. Keep the profile, raw session.json/status.jsonl, and raw process-capture sidecar (which contains the absolute isolated path) local; never upload them.

5. Serial Windows user rows AH

A — isolated first run, real DAT bake, and release-A client baseline

  1. Confirm the launcher opens First-run setup and all launch buttons are unavailable. Save a redacted screenshot as A-first-run-required.png.
  2. Enter <ABSOLUTE_RETAIL_DAT_DIRECTORY> in the wizard, select a sensible worker count, and click Validate. Confirm all four DATs pass.
  3. Click Build and install. Do not cancel or close the launcher. The real bake may take 30180 minutes. Confirm every phase reaches Completed and the status says Client content installed and verified. Launch is enabled.
  4. Open Check for updates. Confirm available release A, click Install client, and wait for Client update installed and activated. Do not stage launcher A; the test launcher already has version A.
  5. Confirm these exact isolated artifacts exist and no .previous-install remains after success:
$RequiredA = @(
  (Join-Path $WinData 'install.json'),
  (Join-Path $WinData 'pak/acdream.pak'),
  (Join-Path $WinData 'app/current.json'))
foreach ($Path in $RequiredA) {
    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Missing $Path" }
}
$RequiredA | ForEach-Object {
    $Item = Get-Item -LiteralPath $_
    [ordered]@{
        name = $Item.Name
        size = $Item.Length
        sha256 = (Get-FileHash -LiteralPath $_ -Algorithm SHA256).Hash.ToLowerInvariant()
    }
} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Evidence 'A-install-hashes.json')

Do not copy install.json into shared evidence because it records the local DAT path. Expected time: 45200 minutes including bake.

B — server/account CRUD entirely through the UI

  1. Add <LA11_TEMP_SERVER> at 127.0.0.1:<UNUSED_LOCAL_PORT>, edit its name and port, then remove it. Confirm Cancel/Escape makes no mutation.
  2. Add <LA11_SERVER> at 127.0.0.1:<ACE_PORT>.
  3. Under it add <LA11_TEMP_ACCOUNT> with a user-invented throwaway field value, edit its account name/value, then remove it. Do not reuse a real password for this temporary row.
  4. Add <LA11_ACCOUNT> and enter its real password only in the masked field.
  5. Close and reopen the launcher with the same $LauncherArguments. Confirm only the real server/account persisted. Save redacted before/reopen images as B-crud-before-reopen.png and B-crud-after-reopen.png.
  6. Record only the profile file's size/hash, never its contents:
$Profile = Join-Path $WinConfig 'launcher-profiles.json'
$Item = Get-Item -LiteralPath $Profile
[ordered]@{
    size = $Item.Length
    sha256 = (Get-FileHash -LiteralPath $Profile -Algorithm SHA256).Hash.ToLowerInvariant()
} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Evidence 'B-profile-hash.json')

Expected time: 1015 minutes.

C — live character probe twice, no stale ACE session

  1. Select <LA11_ACCOUNT>, click Refresh characters, and wait for the probe row to finish. Confirm the roster appears without entering world.
  2. Run the validator in probe mode for its session id. Confirm its exact event order is started, connected, characterList, disconnected, exited, with no enteredWorld, terminal code 0, and terminal reason probe.
  3. In the ACE console/session administration view, confirm the account is no longer logged in. Save a redacted C-probe-1-ace-cleared.png.
  4. Repeat steps 13 immediately, producing a different session id, C-probe-2-status.validation.json, and C-probe-2-ace-cleared.png.
  5. Confirm Refresh is re-enabled and no acdream-headless process remains.

Expected time: 510 minutes. A stale ACE account or timeout is a gate failure; do not wait three minutes and call the next attempt a pass.

D — guiSelect, retail character screen, plugin, and login command

  1. Select one non-disposable roster character. Set its mode to guiSelect, Plugins to exactly acdream.smoke, and its one login command to /tell <OBSERVER_CHARACTER>, LA11-D-<UNIQUE_NONSECRET_NONCE>. Save settings.
  2. Click GUI — character select. Confirm the flat retail character list, selection highlight, Enter button, Delete/Restore swap state, dialogs, and absence of any invented rotating 3D preview. Save redacted D-character-select.png.
  3. Select the configured character and enter world. Confirm the observer gets the exact D nonce once. Save D-observer-tell.png with names redacted.
  4. Click Stop in the launcher. Confirm the game closes gracefully and ACE releases the account. Validate guiSelect with -ExpectedPlugin acdream.smoke.

Expected time: 510 minutes.

E — direct gui, plugin, and login command

  1. Change the same character to gui, retain acdream.smoke, and change the command nonce to LA11-E-<UNIQUE_NONSECRET_NONCE>.
  2. Click GUI — enter world. Confirm it selects the exact cached character, reaches the world, loads the plugin once, and the observer gets the E nonce once.
  3. Stop from the launcher, confirm ACE logout, and validate gui with the expected plugin. Save E-world.png, E-observer-tell.png, and E-status.validation.json with identifying text redacted.

Expected time: 510 minutes.

F — headless, plugin/login command, and connected #397 acceptance

  1. Change the same character to headless, retain acdream.smoke, and use LA11-F-<UNIQUE_NONSECRET_NONCE>.
  2. Click Headless. Confirm pluginLoaded(acdream.smoke), enteredWorld, and the observer's single exact F nonce.
  3. Click Stop once. On Windows this must target that child's distinct process group with CTRL_BREAK; it must reach disconnected then exited(code:0, reason:graceful) before the timeout, without a hard kill. ACE must release the account immediately and the launcher must stay open.
  4. Validate headless with the expected plugin and save F-status.validation.json plus redacted ACE-clear evidence.

The real automated fixture separately proves complex argv and redirected stdin survive native CreateProcessW, the target receives CTRL_BREAK, a sibling process group receives nothing, exit 0 precedes timeout, and Kill is never called. This connected row proves the actual ACE graceful-logout half. Issue #397 remains open if either half is missing. Expected time: 510 minutes.

G — disposable delete and restore

  1. Launch guiSelect for <DISPOSABLE_CHARACTER>. Do not enter world.
  2. Confirm ordinary selection enables Enter/Delete and disables Restore. Click Delete, inspect the retail confirmation dialog, cancel once, and confirm no state change.
  3. Delete again and confirm. Verify the wait dialog, greyed/pending-delete roster state, constant boolean-ish nonzero secondsGreyedOut, disabled Enter/Delete, and enabled Restore. The UI must display no countdown. Save G-deleted.png.
  4. Click Restore and confirm the same GUID returns to ordinary state with Enter/Delete enabled and Restore disabled. Save G-restored.png.
  5. Close through launcher Stop, confirm graceful terminal status and ACE release. Validate with:
pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') `
  -StatusFile (Join-Path $WinCache 'launcher/sessions/<SESSION_ID>/status.jsonl') `
  -Mode guiSelect `
  -ProcessCapturePath (Join-Path $Evidence 'G-process.capture.json') `
  -CredentialProfilePath (Join-Path $WinConfig 'launcher-profiles.json') `
  -ExpectNoEnteredWorld `
  -ExpectedSessionId '<SESSION_ID>' `
  -ExpectedPlugin acdream.smoke `
  -ReportPath (Join-Path $Evidence 'G-status.validation.json')

If restore fails, stop the row, preserve evidence, and restore only that disposable character with the server's normal admin recovery. Never continue with another character. Expected time: 510 minutes.

H — local A→B client update, active-session refusal, rollback, self-update

  1. Record release A's app/current.json. Start one headless session and wait for enteredWorld.
  2. Switch the fixture atomically to B:
pwsh -NoProfile -File (Join-Path $Fixture 'set-active-release.ps1') `
  -Release B -Root $Fixture
if ((Invoke-RestMethod -Uri $ManifestUri).version -cne $VersionB) {
    throw 'Stop: fixture did not switch to B.'
}
  1. Open Check for updates and Check again. While the session is active, confirm Install client, Rollback client, and Stage launcher are disabled or refuse without changing app/current.json. Save H-active-refusal.png.
  2. Stop the headless session and validate its graceful status. Install client B. Confirm app/current.json names B, A is previous, all installed-file hashes verify, and new sessions resolve from the B directory.
  3. Click Rollback client. Confirm A becomes current and B becomes previous. Check again and install B once more, leaving B current. Save sanitized copies of the three pointer states as H-pointer-a.json, H-pointer-b.json, and H-pointer-rollback-a.json; they contain no credentials.
  4. Click Stage launcher. Confirm restart is required, then close the launcher normally. The copied helper must apply B and restart the launcher with the same config/data/cache/feed suffix.
  5. Confirm profiles, install record, and update state still come from the isolated roots; campaign-la-fixture-release.txt beside the relaunched executable says release=B; launcher-update/pending.json is gone; and no transaction backup remains. Check again and confirm launcher B is current. Save H-self-update-confirmed.png and a hash-only post-state inventory.

Never edit a manifest to force this row and never point the launcher at a non-loopback HTTP endpoint. Expected time: 1530 minutes.

6. Row I — native Ubuntu/WSL launcher, XDG-shaped isolated roots

Stop the Windows launcher and fixture server only after every Windows session is terminal:

if (-not $FixtureServer.HasExited) {
    $FixtureServer.Kill()
    $FixtureServer.WaitForExit()
}

In a native Ubuntu/WSL PowerShell 7 terminal, set Linux paths. The repository and fixture may be read from a mounted Windows path, but roots must live on the Linux filesystem. Run the generated server natively so its 127.0.0.1 URLs cannot escape the Linux environment:

$RepoLinux = [IO.Path]::GetFullPath('<ABSOLUTE_LINUX_REPOSITORY_PATH>')
$FixtureLinux = [IO.Path]::GetFullPath('<ABSOLUTE_LINUX_FIXTURE_PATH>')
$PayloadsLinux = [IO.Path]::GetFullPath('<ABSOLUTE_LINUX_PAYLOADS_PATH>')
$LinuxGate = [IO.Path]::GetFullPath('<NEW_ABSOLUTE_LINUX_GATE_ROOT>')
$env:XDG_CONFIG_HOME = Join-Path $LinuxGate 'xdg-config-home'
$env:XDG_DATA_HOME = Join-Path $LinuxGate 'xdg-data-home'
$env:XDG_CACHE_HOME = Join-Path $LinuxGate 'xdg-cache-home'
$LinuxConfig = Join-Path $env:XDG_CONFIG_HOME 'acdream'
$LinuxData = Join-Path $env:XDG_DATA_HOME 'acdream'
$LinuxCache = Join-Path $env:XDG_CACHE_HOME 'acdream'
$LinuxEvidence = Join-Path $LinuxGate 'evidence'
New-Item -ItemType Directory -Path $LinuxEvidence | Out-Null

pwsh -NoProfile -File (Join-Path $FixtureLinux 'set-active-release.ps1') `
  -Release A -Root $FixtureLinux

Start serve-fixture.ps1 -Root $FixtureLinux -Port 43119 in a dedicated native terminal and leave it running. In another terminal:

$LauncherLinuxA = Join-Path $PayloadsLinux 'A/launcher-linux-x64/acdream-launcher'
& $LauncherLinuxA `
  --config-dir $LinuxConfig `
  --data-dir $LinuxData `
  --cache-dir $LinuxCache `
  --update-manifest-uri 'http://127.0.0.1:43119/manifest.json'

Complete this exact serial matrix:

  1. Manual-DAT first run: enter <ABSOLUTE_LINUX_RETAIL_DAT_DIRECTORY>; auto-detection may be empty by design. Validate, bake to $LinuxData/pak/acdream.pak, verify, then install release-A client.
  2. CRUD: add/edit/remove a temporary server and account entirely in the launcher, then add the real Linux-reachable ACE profile. Enter its password only in the masked field. Restart and confirm persistence. Run stat -c '%a' "$LinuxConfig/launcher-profiles.json"; the exact result must be 600.
  3. Probe twice: run Refresh twice, validate both status streams in probe mode with native pwsh, using the same pre-action watcher and exact PID, Linux session-config path, and $LinuxConfig/launcher-profiles.json; confirm ACE clears the account after each.
  4. Platform posture: confirm GUI and GUI-select client buttons are disabled and show the explicit Modern Runtime Slice-L message. Do not bypass this disablement and do not claim a Linux graphical-client gate.
  5. Headless: configure acdream.smoke and /tell <OBSERVER_CHARACTER>, LA11-I-<UNIQUE_NONSECRET_NONCE>, launch, observe the tell, click Stop, and validate headless + expected plugin. Native Linux sends SIGINT and must reach graceful terminal status with no process leak.
  6. Update: switch the native fixture to B, prove update actions refuse while a headless session is active, stop it gracefully, install B, rollback to A, reinstall B, stage launcher B, and close normally. Confirm the relaunched binary's B marker, preserved explicit roots/feed, cleaned pending journal, and executable owner bits on App, Headless, Launcher, and Bake.

Copy only redacted screenshots, validation reports, pointer JSON, hashes, and file-mode results into $LinuxEvidence. Keep the Linux profile and raw session files local. Expected time: 60220 minutes, dominated by the real bake.

7. Evidence, redaction, verdict, and cleanup

Expected evidence tree:

logs/campaign-la-user-gate-<timestamp>/
  automated-preflight/report.json
  automated-preflight/commands/*.log
  automated-preflight/publish/{win-x64,linux-x64}/...
  update-fixture/fixture-report.json
  update-fixture/{A,B}/manifest.json
  evidence/A-install-hashes.json
  evidence/B-*.png
  evidence/C-probe-{1,2}-status.validation.json
  evidence/*-process.capture.json
  evidence/D-*.png + D-status.validation.json
  evidence/E-*.png + E-status.validation.json
  evidence/F-*.png + F-status.validation.json
  evidence/G-*.png + G-status.validation.json
  evidence/H-*.png + H-pointer-*.json
  evidence/I-*.png + I-status.validation.json + I-modes.txt
  verdict.json

Before sharing evidence:

  • remove or mask account names, character names, DAT paths, hostnames other than loopback, and server-admin identifiers from screenshots;
  • never copy launcher-profiles.json, raw session configs/status streams, stdout/stderr that may contain user text, or environment values;
  • search the shareable evidence for the exact user-entered password and any gate-only sentinel secret; the match count must be zero;
  • retain SHA-256 and sizes so local raw artifacts remain auditable.

No additional raw child/plugin diagnostic sink is required: pluginLoaded, the strict terminal status, the observer's redacted tell evidence, and the automated targeted-signal fixture cover the acceptance questions without capturing credentials or arbitrary chat.

Create verdict.json manually with schema version 1, exact tested HEAD, rows AI as pass, fail, or notApplicable, a short redacted note per row, and the user's overall verdict. Row I is not applicable only when no native Ubuntu/WSL desktop is available; it blocks Campaign LA shipping under the current Linux requirement, so it cannot be silently omitted.

Cleanup is graceful-first and serial:

  1. Restore <DISPOSABLE_CHARACTER> and verify it is ordinary before closing its session.
  2. Stop every launcher session once; require a passing validator and ACE-clear observation. If a child survives the timeout, record the gate failure and its PID before any emergency termination.
  3. Close each launcher normally, then stop only the fixture-server process created above. Do not kill ACE as a substitute for logout evidence.
  4. Leave update pointers on B or roll the isolated client back to A through the UI; never edit pointers or journals by hand.
  5. Remove the real account through the isolated launcher UI. After review, delete only the explicitly recorded $WinConfig/$LinuxConfig gate roots that held plaintext passwords, or change the test account password. Do not recursively delete a computed, empty, canonical, home, repository, or XDG parent path.
  6. Preserve the redacted evidence and reports. The large isolated pak/payload trees may be removed only after resolving and checking their full paths are descendants of the recorded gate roots.

Estimated total: 37 hours, primarily the two real DAT bakes and full serial test suites. A failure stops the current row; restore/stop/collect evidence, then diagnose before advancing. Do not mark LA11, Campaign LA, or #397 shipped until the user accepts the complete applicable matrix.