feat(launcher): LU1 — stop hashing 28 GB before the launcher window appears

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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-19 18:40:34 +02:00
parent a34e8f2a17
commit 00d1278228
8 changed files with 665 additions and 22 deletions

View file

@ -0,0 +1,223 @@
using AcDream.Launcher.Core.Installation;
using AcDream.Launcher.Core.Integrity;
using AcDream.Launcher.Core.Launching;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Tests.Installation;
/// <summary>
/// LU1. Ordinary startup used to SHA-256 the whole installed package before
/// the launcher window was constructed. That package is ~28 GiB in practice
/// and the hash measured 24.1 s, so the launcher took roughly half a minute
/// to appear while re-confirming a digest that had not changed since the
/// install wrote it.
///
/// <para>These tests pin the replacement: the cheap facts (size, last-write
/// time) still run on every startup, the hash is skipped only when a previous
/// full hash of that same file agreed with the install record, and every way
/// that agreement can be false falls back to hashing.</para>
/// </summary>
[Collection(WorkingDirectoryCollection.Name)]
public sealed class PreparedAssetVerificationCacheTests : IDisposable
{
private const string PackageContent = "verified package";
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-verification-cache-tests",
Guid.NewGuid().ToString("N"));
private readonly ApplicationPathSet _paths;
private readonly string _dats;
private int _hashCount;
public PreparedAssetVerificationCacheTests()
{
_paths = new ApplicationPathSet(
Path.Combine(_root, "config"),
Path.Combine(_root, "data"),
Path.Combine(_root, "cache"),
null);
_dats = Path.Combine(_root, "retail-dats");
Directory.CreateDirectory(_dats);
foreach (string fileName in DatDirectoryLocator.RequiredFileNames)
{
File.WriteAllText(Path.Combine(_dats, fileName), "fixture");
}
}
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public async Task SecondStartupSkipsTheHashEntirely()
{
LauncherInstallRecordStore store = await CreateInstalledStoreAsync();
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
Assert.Equal(1, _hashCount);
// The whole point of the slice: nothing is read from the package the
// second time around.
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
Assert.Equal(1, _hashCount);
}
[Fact]
public async Task ForcedFullVerificationHashesEvenWithAValidCache()
{
LauncherInstallRecordStore store = await CreateInstalledStoreAsync();
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
Assert.Equal(1, _hashCount);
Assert.True((await store.LoadAndVerifyAsync(forceFullVerification: true))
.IsVerified);
Assert.Equal(2, _hashCount);
}
[Fact]
public async Task ATouchedPackageIsHashedAgain()
{
LauncherInstallRecordStore store = await CreateInstalledStoreAsync();
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
Assert.Equal(1, _hashCount);
// Same bytes, new write time: the cheap facts no longer match, so the
// expensive one has to run. It still verifies, because the content is
// in fact unchanged.
File.SetLastWriteTimeUtc(
store.PreparedAssetPath,
File.GetLastWriteTimeUtc(store.PreparedAssetPath).AddMinutes(5));
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
Assert.Equal(2, _hashCount);
}
[Fact]
public async Task ASilentlyCorruptedPackageOfTheSameSizeStillFailsAndDropsTheCache()
{
LauncherInstallRecordStore store = await CreateInstalledStoreAsync();
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
// Identical length so the size check cannot catch it, and a fresh
// write time so the cache cannot be believed.
await File.WriteAllTextAsync(
store.PreparedAssetPath,
new string('x', PackageContent.Length));
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
Assert.False(verification.IsVerified);
Assert.Contains("SHA-256", verification.Status, StringComparison.Ordinal);
Assert.False(File.Exists(CachePath));
}
[Fact]
public async Task AResizedPackageIsRejectedWithoutHashingIt()
{
LauncherInstallRecordStore store = await CreateInstalledStoreAsync();
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
Assert.Equal(1, _hashCount);
await File.WriteAllTextAsync(store.PreparedAssetPath, PackageContent + "!");
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
Assert.False(verification.IsVerified);
Assert.Contains("size changed", verification.Status, StringComparison.Ordinal);
Assert.Equal(1, _hashCount);
}
[Fact]
public async Task ACacheWhoseDigestDisagreesWithTheRecordIsIgnored()
{
LauncherInstallRecordStore store = await CreateInstalledStoreAsync();
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
Assert.Equal(1, _hashCount);
// A cache entry that matches the FILE but not the RECORD must never
// satisfy verification — otherwise a stale entry could vouch for a
// package the current record does not describe.
await File.WriteAllTextAsync(
CachePath,
(await File.ReadAllTextAsync(CachePath)).Replace(
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
new string('a', 64),
StringComparison.OrdinalIgnoreCase));
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
Assert.Equal(2, _hashCount);
}
[Theory]
[InlineData("")]
[InlineData("{ not json")]
[InlineData("{\"version\":9999}")]
public async Task AnUnreadableCacheDegradesToHashingRatherThanFailing(string content)
{
LauncherInstallRecordStore store = await CreateInstalledStoreAsync();
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
Assert.Equal(1, _hashCount);
await File.WriteAllTextAsync(CachePath, content);
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
Assert.Equal(2, _hashCount);
}
[Fact]
public async Task BackupRecoveryHashesTheBackupEvenWhenTheLiveCacheIsValid()
{
LauncherInstallRecordStore store = await CreateInstalledStoreAsync();
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
Assert.Equal(1, _hashCount);
// Crash shape: the verified package was moved aside and what sits in
// its place is wrong. The cache still describes the ORIGINAL bytes, so
// if recovery trusted it the launcher would accept a bad package.
string backup = LauncherInstallRecordStore.GetBackupPath(store.PreparedAssetPath);
File.Move(store.PreparedAssetPath, backup);
await File.WriteAllTextAsync(
store.PreparedAssetPath,
new string('z', PackageContent.Length));
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
Assert.True(verification.IsVerified);
Assert.Equal(PackageContent, await File.ReadAllTextAsync(store.PreparedAssetPath));
Assert.False(File.Exists(backup));
// Live package hashed (and failed), then the backup hashed.
Assert.Equal(3, _hashCount);
}
private string CachePath => Path.Combine(
Path.GetFullPath(_paths.DataDirectory),
"install.verification.json");
private async Task<LauncherInstallRecordStore> CreateInstalledStoreAsync()
{
var store = new LauncherInstallRecordStore(
_paths,
datDirectories: null,
computeSha256: (path, cancellationToken) =>
{
Interlocked.Increment(ref _hashCount);
return FileIntegrity.ComputeSha256HexAsync(path, cancellationToken);
});
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
await File.WriteAllTextAsync(store.PreparedAssetPath, PackageContent);
var info = new FileInfo(store.PreparedAssetPath);
await store.SaveAtomicallyAsync(new LauncherInstallRecord(
Path.GetFullPath(_dats),
Path.GetFullPath(store.PreparedAssetPath),
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
info.Length,
LauncherInstallRecordStore.CurrentBakeToolVersion));
return store;
}
}

View file

@ -338,6 +338,10 @@ public sealed class LauncherWindowViewModelTests
var installer = new FakeLauncherInstaller();
using var viewModel = CreateInitialized(orchestrator, installer);
// LU1: nothing has asked for a verification yet. Startup's own
// verification happens in the composition root, not here.
Assert.Empty(installer.LoadExistingCalls);
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
Assert.Equal(installer.DetectedDirectory, viewModel.FirstRunWizardShell.DatDirectory);
@ -433,6 +437,77 @@ public sealed class LauncherWindowViewModelTests
Assert.True(orchestrator.ClearCalled);
}
/// <summary>
/// LU1. Ordinary startup trusts a remembered digest so the window is not
/// held behind a multi-second hash of a ~28 GiB file; "Verify files" is
/// the deliberate way to make it read the whole package again, so it must
/// force the full hash rather than hit the same fast path.
/// </summary>
[Fact]
public async Task VerifyFilesForcesAFullHashAndPublishesTheResult()
{
// Verification is gated on no session running, same as install and
// update: a failed verification clears the install record, and doing
// that under a live client would be incoherent.
using var orchestrator = new FakeLauncherOrchestrator
{
Session = FakeLauncherOrchestrator.CreateSession(
LauncherActivityState.Exited,
"Exited cleanly."),
};
var record = new LauncherInstallRecord(
"C:/dats",
"C:/data/pak/acdream.pak",
new string('a', 64),
4096,
4);
var installer = new FakeLauncherInstaller
{
NextVerification = new InstallRecordVerification(
InstallRecordVerificationState.Verified,
record,
"Client content verified."),
};
using var viewModel = CreateInitialized(orchestrator, installer);
await viewModel.VerifyContentCommand.ExecuteAsync();
Assert.Equal([true], installer.LoadExistingCalls);
Assert.Same(record, orchestrator.InstalledRecord);
Assert.Equal("Client content verified.", viewModel.OperationStatus);
Assert.Null(viewModel.LastError);
}
/// <summary>
/// LU1. A package that fails its full verification must clear the install
/// record — the launcher may not start a client against content it just
/// proved is wrong — and must say why.
/// </summary>
[Fact]
public async Task VerifyFilesClearsTheInstallRecordWhenVerificationFails()
{
using var orchestrator = new FakeLauncherOrchestrator
{
Session = FakeLauncherOrchestrator.CreateSession(
LauncherActivityState.Exited,
"Exited cleanly."),
};
var installer = new FakeLauncherInstaller
{
NextVerification = new InstallRecordVerification(
InstallRecordVerificationState.Invalid,
null,
"The prepared package SHA-256 does not match the install record."),
};
using var viewModel = CreateInitialized(orchestrator, installer);
await viewModel.VerifyContentCommand.ExecuteAsync();
Assert.Null(orchestrator.InstalledRecord);
Assert.Contains("SHA-256", viewModel.OperationStatus, StringComparison.Ordinal);
Assert.Contains("SHA-256", viewModel.LastError!, StringComparison.Ordinal);
}
private static LauncherWindowViewModel CreateInitialized(
FakeLauncherOrchestrator orchestrator,
ILauncherInstaller? installer = null)
@ -730,12 +805,26 @@ public sealed class LauncherWindowViewModelTests
"The DAT directory is incomplete.",
DatDirectoryLocator.RequiredFileNames);
public Task<InstallRecordVerification> LoadExistingAsync(
CancellationToken cancellationToken = default) =>
Task.FromResult(new InstallRecordVerification(
/// <summary>LU1: what the next <see cref="LoadExistingAsync"/> returns.
/// Defaults to "not installed", which is what every pre-existing test
/// in this file expects.</summary>
public InstallRecordVerification NextVerification { get; set; } =
new(
InstallRecordVerificationState.Missing,
null,
"Client content is not installed."));
"Client content is not installed.");
/// <summary>LU1: the <c>forceFullVerification</c> values this fake was
/// called with, oldest first.</summary>
public List<bool> LoadExistingCalls { get; } = [];
public Task<InstallRecordVerification> LoadExistingAsync(
CancellationToken cancellationToken = default,
bool forceFullVerification = false)
{
LoadExistingCalls.Add(forceFullVerification);
return Task.FromResult(NextVerification);
}
public Task<LauncherInstallResult> InstallAsync(
string datDirectory,