feat(launcher): stabilize prepared content updates
Some checks failed
CI / linux-portable (push) Failing after 3m12s
CI / windows-gate (push) Failing after 6m35s
CI / release (push) Has been skipped

This commit is contained in:
Erik 2026-08-25 19:17:13 +02:00
parent f160f3fee1
commit af9327a17b
42 changed files with 3706 additions and 147 deletions

View file

@ -43,4 +43,27 @@ public sealed class BakeProcessRunnerTests
Assert.False(startInfo.Environment.ContainsKey(
BakePublicationGuardPaths.NonceEnvironmentVariable));
}
[Fact]
public void FilteredOverlayArgumentsUseBakeToolsPinnedHexLists()
{
var request = new BakeProcessRequest(
"acdream-bake",
"retail-dats",
"data/pak/update.pak",
4,
DatIds: [0x01000001u, 0x02000002u],
Landblocks: [0x0A, 0xFE]);
Assert.Equal(
[
"--dat-dir", "retail-dats",
"--out", "data/pak/update.pak",
"--threads", "4",
"--progress-json",
"--ids", "0x01000001,0x02000002",
"--landblocks", "0x0A,0xFE",
],
request.Arguments);
}
}

View file

@ -0,0 +1,156 @@
using System.Buffers.Binary;
using AcDream.Launcher.Core.Integrity;
using AcDream.Launcher.Core.Installation;
using AcDream.Launcher.Core.Launching;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Tests.Installation;
public sealed class LauncherContentStateStoreTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-content-state-tests",
Guid.NewGuid().ToString("N"));
private readonly ApplicationPathSet _paths;
public LauncherContentStateStoreTests()
{
_paths = new ApplicationPathSet(
Path.Combine(_root, "config"),
Path.Combine(_root, "data"),
Path.Combine(_root, "cache"),
null);
}
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public async Task MatchingSidecarResolvesWithoutStartupHashAndForceVerifyHashesBoth()
{
int hashCalls = 0;
var store = new LauncherContentStateStore(
_paths,
async (path, cancellationToken) =>
{
hashCalls++;
return await FileIntegrity.ComputeSha256HexAsync(
path,
cancellationToken);
});
string basePath = Path.Combine(_paths.DataDirectory, "pak", "acdream.pak");
string overlayPath = Path.Combine(
_paths.DataDirectory,
"pak",
"acdream-update-5-test.pak");
WritePakHeader(basePath, recipe: 4);
WritePakHeader(overlayPath, recipe: 5);
LauncherInstallRecord record = await RecordAsync(basePath, recipe: 4);
var overlay = new LauncherContentOverlay(
Path.GetFileName(overlayPath),
await FileIntegrity.ComputeSha256HexAsync(overlayPath),
new FileInfo(overlayPath).Length,
5);
var state = new LauncherContentState(
LauncherContentState.CurrentSchemaVersion,
record.PreparedAssetSha256,
5,
overlay);
await store.SaveAtomicallyAsync(record, state);
(LauncherContentState? quick, string? quickError) =
await store.LoadAsync(record);
Assert.Null(quickError);
Assert.Equal(state, quick);
Assert.Equal(0, hashCalls);
(LauncherContentState? verified, string? verifyError) =
await store.LoadAsync(record, forceFullVerification: true);
Assert.Null(verifyError);
Assert.Equal(state, verified);
Assert.Equal(2, hashCalls);
Assert.Empty(Directory.EnumerateFiles(
Path.GetDirectoryName(store.StatePath)!,
"content.current.json.*.tmp"));
}
[Theory]
[InlineData("../escape.pak")]
[InlineData("nested/escape.pak")]
[InlineData("C:\\escape.pak")]
[InlineData("not-a-pak.txt")]
public async Task OverlayPathMustBeOneContainedPakFilename(string path)
{
var store = new LauncherContentStateStore(_paths);
string basePath = Path.Combine(_paths.DataDirectory, "pak", "acdream.pak");
WritePakHeader(basePath, recipe: 4);
LauncherInstallRecord record = await RecordAsync(basePath, recipe: 4);
var state = new LauncherContentState(
LauncherContentState.CurrentSchemaVersion,
record.PreparedAssetSha256,
5,
new LauncherContentOverlay(path, new string('a', 64), 64, 5));
InvalidDataException error = await Assert.ThrowsAsync<InvalidDataException>(
() => store.SaveAtomicallyAsync(record, state));
Assert.Contains("filename", error.Message, StringComparison.OrdinalIgnoreCase);
Assert.False(File.Exists(store.StatePath));
}
[Fact]
public async Task BaseDigestBindingRejectsSidecarFromPriorFullRebuild()
{
var store = new LauncherContentStateStore(_paths);
string basePath = Path.Combine(_paths.DataDirectory, "pak", "acdream.pak");
WritePakHeader(basePath, recipe: 4);
LauncherInstallRecord record = await RecordAsync(basePath, recipe: 4);
var state = new LauncherContentState(
LauncherContentState.CurrentSchemaVersion,
new string('b', 64),
5,
new LauncherContentOverlay(
"acdream-update-5-test.pak",
new string('a', 64),
64,
5));
InvalidDataException error = await Assert.ThrowsAsync<InvalidDataException>(
() => store.SaveAtomicallyAsync(record, state));
Assert.Contains("base pak", error.Message, StringComparison.OrdinalIgnoreCase);
}
internal static void WritePakHeader(string path, uint recipe)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
byte[] bytes = new byte[64];
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(0, 4), 0x4B504341u);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(4, 4), 1);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(8, 4), 100);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(12, 4), 200);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(16, 4), 300);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(20, 4), 400);
BinaryPrimitives.WriteUInt64LittleEndian(bytes.AsSpan(24, 8), 64);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(36, 4), recipe);
File.WriteAllBytes(path, bytes);
}
internal static async Task<LauncherInstallRecord> RecordAsync(
string basePath,
uint recipe,
string? datDirectory = null) =>
new(
datDirectory ?? Path.GetDirectoryName(basePath)!,
basePath,
await FileIntegrity.ComputeSha256HexAsync(basePath),
new FileInfo(basePath).Length,
recipe);
}

View file

@ -85,7 +85,7 @@ public sealed class LauncherInstallRecordStoreTests : IDisposable
}
[Fact]
public async Task StaleBakeToolVersionIsRejectedBeforeHashing()
public async Task StaleBakeToolVersionPromptsForKnownMigrationBeforeHashing()
{
int hashCalls = 0;
var store = new LauncherInstallRecordStore(
@ -112,8 +112,12 @@ public sealed class LauncherInstallRecordStoreTests : IDisposable
}));
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
Assert.Equal(InstallRecordVerificationState.Invalid, verification.State);
Assert.Contains("Bake tool version", verification.Status, StringComparison.Ordinal);
Assert.Equal(
InstallRecordVerificationState.ContentUpdateRequired,
verification.State);
Assert.NotNull(verification.Record);
Assert.Equal(ContentWorkKind.FullRebuild, verification.RequiredContentWork?.Kind);
Assert.Contains("World data update", verification.Status, StringComparison.Ordinal);
Assert.Equal(0, hashCalls);
}

View file

@ -75,12 +75,14 @@ public sealed class LauncherInstallerTests : IDisposable
Assert.Equal(Path.GetFullPath(_bakeExecutable), observedRequest.ExecutablePath);
Assert.Equal(Path.GetFullPath(_dats), observedRequest.DatDirectory);
Assert.Equal(
Path.Combine(_paths.DataDirectory, "pak", "acdream.pak"),
LauncherInstaller.GetFullRebuildCandidatePath(
Path.Combine(_paths.DataDirectory, "pak", "acdream.pak")),
observedRequest.OutputPath);
Assert.Equal(
[
"--dat-dir", Path.GetFullPath(_dats),
"--out", Path.Combine(_paths.DataDirectory, "pak", "acdream.pak"),
"--out", LauncherInstaller.GetFullRebuildCandidatePath(
Path.Combine(_paths.DataDirectory, "pak", "acdream.pak")),
"--threads", "7",
"--progress-json",
],
@ -145,6 +147,54 @@ public sealed class LauncherInstallerTests : IDisposable
StringComparison.Ordinal);
}
[Fact]
public async Task FullContentMigrationPersistsClientGateAcrossLauncherRestart()
{
var runner = new FakeBakeProcessRunner(async (request, output, _) =>
{
Directory.CreateDirectory(Path.GetDirectoryName(request.OutputPath)!);
await File.WriteAllTextAsync(request.OutputPath, "replacement package");
long bytes = new FileInfo(request.OutputPath).Length;
output($"{{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":"
+ $"{LauncherInstallRecordStore.CurrentBakeToolVersion}}}\n");
output($"{{\"v\":1,\"e\":\"completed\",\"bakeToolVersion\":"
+ $"{LauncherInstallRecordStore.CurrentBakeToolVersion},"
+ $"\"outputBytes\":{bytes},\"failures\":0}}\n");
return new BakeProcessResult(0, string.Empty);
});
var installer = new LauncherInstaller(
_paths,
_bakeExecutable,
processRunner: runner);
var migration = new ContentMigrationPlan(
LauncherInstallRecordStore.CurrentBakeToolVersion - 1,
LauncherInstallRecordStore.CurrentBakeToolVersion,
ContentWorkKind.FullRebuild,
"fixture full migration");
LauncherInstallResult result = await installer.ApplyContentUpdateAsync(
_dats,
2,
migration);
var stateStore = new LauncherContentStateStore(_paths);
Assert.True(result.Record.RequiresClientCompatibilityConfirmation);
Assert.True(File.Exists(stateStore.ClientCompatibilityPendingPath));
var restarted = new LauncherInstaller(
_paths,
_bakeExecutable,
processRunner: runner);
InstallRecordVerification discovered = await restarted.LoadExistingAsync();
Assert.True(discovered.IsVerified);
Assert.True(discovered.Record!.RequiresClientCompatibilityConfirmation);
restarted.ConfirmClientCompatibility();
discovered = await restarted.LoadExistingAsync();
Assert.False(discovered.Record!.RequiresClientCompatibilityConfirmation);
Assert.False(File.Exists(stateStore.ClientCompatibilityPendingPath));
}
[Fact]
public async Task FailedChildRestoresPriorVerifiedPakAndRecord()
{
@ -361,12 +411,15 @@ public sealed class LauncherInstallerTests : IDisposable
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => operationB);
Assert.False(runnerBEntered);
Assert.Equal(
"installer A in progress",
await File.ReadAllTextAsync(storeA.PreparedAssetPath));
Assert.Equal(
"previous verified package",
await File.ReadAllTextAsync(backupPath));
await File.ReadAllTextAsync(storeA.PreparedAssetPath));
Assert.False(File.Exists(backupPath));
Assert.Equal(
"installer A in progress",
await File.ReadAllTextAsync(
LauncherInstaller.GetFullRebuildCandidatePath(
storeA.PreparedAssetPath)));
}
finally
{
@ -547,12 +600,14 @@ public sealed class LauncherInstallerTests : IDisposable
await File.ReadAllTextAsync(childPid),
System.Globalization.CultureInfo.InvariantCulture);
using Process orphan = Process.GetProcessById(orphanPid);
Assert.True(File.Exists(
Assert.False(File.Exists(
LauncherInstallRecordStore.GetBackupPath(
store.PreparedAssetPath)));
string candidatePath = LauncherInstaller.GetFullRebuildCandidatePath(
store.PreparedAssetPath);
Assert.True(File.Exists(
BakePublicationGuardPaths.GetAuthorizationPath(
store.PreparedAssetPath)));
candidatePath)));
parent.Kill(entireProcessTree: false);
await parent.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
@ -580,7 +635,7 @@ public sealed class LauncherInstallerTests : IDisposable
recovered = await recovery.WaitAsync(TimeSpan.FromSeconds(15));
Assert.False(File.Exists(
BakePublicationGuardPaths.GetAuthorizationPath(
store.PreparedAssetPath)));
candidatePath)));
File.WriteAllText(release, "release");
}
@ -622,7 +677,7 @@ public sealed class LauncherInstallerTests : IDisposable
Assert.False(backupAfterRecovery);
Assert.False(File.Exists(
BakePublicationGuardPaths.GetAuthorizationPath(
store.PreparedAssetPath)));
candidatePath)));
}
finally
{

View file

@ -0,0 +1,218 @@
using System.Text.Json;
using AcDream.Launcher.Core.Integrity;
using AcDream.Launcher.Core.Installation;
using AcDream.Launcher.Core.Launching;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Tests.Installation;
public sealed class LauncherOverlayInstallerTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-overlay-installer-tests",
Guid.NewGuid().ToString("N"));
private readonly ApplicationPathSet _paths;
private readonly string _dats;
private readonly string _bakeExecutable;
public LauncherOverlayInstallerTests()
{
_paths = new ApplicationPathSet(
Path.Combine(_root, "config"),
Path.Combine(_root, "data"),
Path.Combine(_root, "cache"),
null);
_dats = Path.Combine(_root, "dats");
foreach (string name in DatDirectoryLocator.RequiredFileNames)
{
Directory.CreateDirectory(_dats);
File.WriteAllText(Path.Combine(_dats, name), name);
}
_bakeExecutable = Path.Combine(_root, "bin", "acdream-bake");
Directory.CreateDirectory(Path.GetDirectoryName(_bakeExecutable)!);
File.WriteAllText(_bakeExecutable, "fixture");
}
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public async Task FilteredBakePublishesOneOverlayWithoutRewritingBase()
{
var recordStore = new LauncherInstallRecordStore(_paths);
LauncherInstallRecord baseRecord = await CreateBaseRecordAsync(recordStore);
byte[] baseBefore = await File.ReadAllBytesAsync(
recordStore.PreparedAssetPath);
BakeProcessRequest? observed = null;
var runner = new FakeRunner(async (request, output, _) =>
{
observed = request;
LauncherContentStateStoreTests.WritePakHeader(
request.OutputPath,
LauncherInstallRecordStore.CurrentBakeToolVersion);
long bytes = new FileInfo(request.OutputPath).Length;
output($"{{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":5}}\n");
output($"{{\"v\":1,\"e\":\"completed\",\"bakeToolVersion\":5,"
+ $"\"outputBytes\":{bytes},\"failures\":0}}\n");
await Task.Yield();
return new BakeProcessResult(0, string.Empty);
});
var installer = new LauncherInstaller(
_paths,
_bakeExecutable,
recordStore: recordStore,
processRunner: runner);
var migration = new ContentMigrationPlan(
4,
5,
ContentWorkKind.Overlay,
"bounded fixture update",
[0x01001234u, 0x02005678u],
[0xAB]);
LauncherInstallResult result = await installer.ApplyContentUpdateAsync(
_dats,
3,
migration);
Assert.Equal(baseBefore, await File.ReadAllBytesAsync(
recordStore.PreparedAssetPath));
Assert.NotNull(observed);
Assert.Equal(
new[] { "--ids", "0x01001234,0x02005678" },
observed.Arguments.SkipWhile(value => value != "--ids").Take(2));
Assert.Equal(
new[] { "--landblocks", "0xAB" },
observed.Arguments.SkipWhile(value => value != "--landblocks").Take(2));
Assert.NotNull(result.Record.PreparedAssetOverlayPath);
Assert.True(File.Exists(result.Record.PreparedAssetOverlayPath));
Assert.Equal(5u, result.Record.ResolvedBakeToolVersion);
Assert.True(result.Record.RequiresClientCompatibilityConfirmation);
Assert.False(File.Exists(
new LauncherContentStateStore(_paths).OverlayCandidatePath));
var restartedInstaller = new LauncherInstaller(
_paths,
_bakeExecutable,
recordStore: recordStore,
processRunner: runner);
InstallRecordVerification discovered =
await restartedInstaller.LoadExistingAsync();
Assert.True(discovered.IsVerified);
Assert.True(discovered.Record?.RequiresClientCompatibilityConfirmation);
Assert.Equal(
result.Record.PreparedAssetOverlayPath,
discovered.Record?.PreparedAssetOverlayPath);
restartedInstaller.ConfirmClientCompatibility();
discovered = await restartedInstaller.LoadExistingAsync();
Assert.True(discovered.IsVerified);
Assert.False(discovered.Record?.RequiresClientCompatibilityConfirmation);
string json = SessionConfigComposer.Serialize(
SessionConfigComposer.Compose(
new() { Name = "server", Host = "localhost", Port = 9000 },
new() { Account = "account", Password = "secret" },
new()
{
Name = "character",
LaunchMode = AcDream.Launcher.Core.Profiles.LaunchMode.Gui,
},
discovered.Record!,
_paths,
"overlay-session").Document);
Assert.Contains("preparedAssetOverlayPath", json, StringComparison.Ordinal);
Assert.Contains("preparedAssetBaseRecipeVersion", json, StringComparison.Ordinal);
Assert.DoesNotContain(baseRecord.PreparedAssetSha256, json, StringComparison.Ordinal);
}
[Fact]
public async Task CancellationLeavesBaseRecordAndSidecarUntouched()
{
var recordStore = new LauncherInstallRecordStore(_paths);
LauncherInstallRecord baseRecord = await CreateBaseRecordAsync(recordStore);
string recordBefore = await File.ReadAllTextAsync(recordStore.RecordPath);
byte[] baseBefore = await File.ReadAllBytesAsync(
recordStore.PreparedAssetPath);
var entered = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
var runner = new FakeRunner(async (request, _, cancellationToken) =>
{
LauncherContentStateStoreTests.WritePakHeader(request.OutputPath, 5);
entered.SetResult();
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
return new BakeProcessResult(0, string.Empty);
});
var installer = new LauncherInstaller(
_paths,
_bakeExecutable,
recordStore: recordStore,
processRunner: runner);
using var cancellation = new CancellationTokenSource();
Task<LauncherInstallResult> operation = installer.ApplyContentUpdateAsync(
_dats,
2,
new ContentMigrationPlan(
4,
5,
ContentWorkKind.Overlay,
"bounded fixture update",
[0x01001234u]),
cancellationToken: cancellation.Token);
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => operation);
Assert.Equal(baseBefore, await File.ReadAllBytesAsync(
recordStore.PreparedAssetPath));
Assert.Equal(recordBefore, await File.ReadAllTextAsync(recordStore.RecordPath));
var stateStore = new LauncherContentStateStore(_paths);
Assert.False(File.Exists(stateStore.StatePath));
Assert.False(File.Exists(stateStore.OverlayCandidatePath));
Assert.False(File.Exists(stateStore.ClientCompatibilityPendingPath));
Assert.Equal(
InstallRecordVerificationState.ContentUpdateRequired,
(await installer.LoadExistingAsync()).State);
Assert.Equal(baseRecord, (await recordStore.LoadAndVerifyAsync()).Record);
}
private async Task<LauncherInstallRecord> CreateBaseRecordAsync(
LauncherInstallRecordStore recordStore)
{
LauncherContentStateStoreTests.WritePakHeader(
recordStore.PreparedAssetPath,
recipe: 4);
LauncherInstallRecord baseRecord =
await LauncherContentStateStoreTests.RecordAsync(
recordStore.PreparedAssetPath,
recipe: 4,
datDirectory: Path.GetFullPath(_dats));
Directory.CreateDirectory(_paths.DataDirectory);
await File.WriteAllTextAsync(
recordStore.RecordPath,
JsonSerializer.Serialize(baseRecord, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
}));
return baseRecord;
}
private sealed class FakeRunner(
Func<BakeProcessRequest, Action<string>, CancellationToken, Task<BakeProcessResult>> handler)
: IBakeProcessRunner
{
public Task<BakeProcessResult> RunAsync(
BakeProcessRequest request,
Action<string> onStandardOutput,
CancellationToken cancellationToken = default) =>
handler(request, onStandardOutput, cancellationToken);
}
}

View file

@ -67,6 +67,21 @@ public sealed class PreparedAssetVerificationCacheTests : IDisposable
Assert.Equal(1, _hashCount);
}
[Fact]
public async Task CacheMissReportsTheExceptionalLongReadBeforeHashing()
{
LauncherInstallRecordStore store = await CreateInstalledStoreAsync();
var statuses = new List<string>();
Assert.True((await store.LoadAndVerifyAsync(
progress: new ImmediateProgress(statuses.Add))).IsVerified);
Assert.Contains(
statuses,
status => status.Contains("whole world-data pak", StringComparison.Ordinal)
&& status.Contains("30 seconds", StringComparison.Ordinal));
}
[Fact]
public async Task ForcedFullVerificationHashesEvenWithAValidCache()
{
@ -264,4 +279,9 @@ public sealed class PreparedAssetVerificationCacheTests : IDisposable
LauncherInstallRecordStore.CurrentBakeToolVersion));
return store;
}
private sealed class ImmediateProgress(Action<string> report) : IProgress<string>
{
public void Report(string value) => report(value);
}
}