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

@ -51,6 +51,9 @@ internal sealed record ContentEffectsAudioResult(
internal sealed record ContentEffectsAudioDependencies(
string DatDirectory,
string PreparedAssetPath,
string? PreparedAssetOverlayPath,
uint? PreparedAssetBaseRecipeVersion,
uint? PreparedAssetEffectiveRecipeVersion,
ResidencyBudgetOptions ResidencyBudgets,
PhysicsDataCache PhysicsDataCache,
bool DumpMotionEnabled,
@ -99,6 +102,9 @@ internal interface IContentEffectsAudioCompositionFactory
IDatReaderWriter OpenDatCollection(string datDirectory);
IPreparedAssetSource OpenPreparedAssetSource(
string path,
string? overlayPath,
uint? baseRecipeVersion,
uint? effectiveRecipeVersion,
IDatReaderWriter dats,
Action<string> diagnostic);
MagicCatalog LoadMagicCatalog(IDatReaderWriter dats);
@ -169,9 +175,54 @@ internal sealed class RetailContentEffectsAudioCompositionFactory
public IPreparedAssetSource OpenPreparedAssetSource(
string path,
string? overlayPath,
uint? baseRecipeVersion,
uint? effectiveRecipeVersion,
IDatReaderWriter dats,
Action<string> diagnostic) =>
new PakPreparedAssetSource(path, dats, diagnostic);
Action<string> diagnostic)
{
if (string.IsNullOrWhiteSpace(overlayPath))
{
return new PakPreparedAssetSource(path, dats, diagnostic);
}
if (baseRecipeVersion is not > 0
|| effectiveRecipeVersion is not > 0)
{
throw new InvalidDataException(
"Layered prepared content is missing its recipe identities.");
}
if (effectiveRecipeVersion
!= AcDream.Content.Pak.PakFormat.CurrentBakeToolVersion)
{
throw new InvalidDataException(
$"Prepared content recipe {effectiveRecipeVersion} does not "
+ $"match client recipe "
+ $"{AcDream.Content.Pak.PakFormat.CurrentBakeToolVersion}.");
}
PakPreparedAssetSource? baseSource = null;
PakPreparedAssetSource? overlaySource = null;
try
{
baseSource = new PakPreparedAssetSource(
path,
PreparedAssetCatalogIdentity.From(dats, baseRecipeVersion.Value),
diagnostic);
overlaySource = new PakPreparedAssetSource(
overlayPath,
PreparedAssetCatalogIdentity.From(dats, effectiveRecipeVersion.Value),
diagnostic);
return new LayeredPreparedAssetSource(baseSource, overlaySource);
}
catch
{
overlaySource?.Dispose();
baseSource?.Dispose();
throw;
}
}
public MagicCatalog LoadMagicCatalog(IDatReaderWriter dats) =>
MagicCatalog.Load(dats);
@ -370,6 +421,9 @@ internal sealed class ContentEffectsAudioCompositionPhase :
"prepared asset source",
() => _factory.OpenPreparedAssetSource(
_dependencies.PreparedAssetPath,
_dependencies.PreparedAssetOverlayPath,
_dependencies.PreparedAssetBaseRecipeVersion,
_dependencies.PreparedAssetEffectiveRecipeVersion,
dats,
_dependencies.Error),
static value => value.Dispose()).Publish(

View file

@ -71,6 +71,12 @@ internal sealed class SessionContentDescriptor
[JsonRequired]
public string PreparedAssetPath { get; init; } = string.Empty;
public string? PreparedAssetOverlayPath { get; init; }
public uint? PreparedAssetBaseRecipeVersion { get; init; }
public uint? PreparedAssetEffectiveRecipeVersion { get; init; }
}
internal sealed record SessionDescriptor

View file

@ -83,6 +83,18 @@ internal static class SessionConfigurationLoader
throw new SessionConfigurationException(
"process.content requires non-empty datDirectory and preparedAssetPath.");
}
bool hasOverlay = !string.IsNullOrWhiteSpace(
content.PreparedAssetOverlayPath);
bool hasBaseRecipe = content.PreparedAssetBaseRecipeVersion is > 0;
bool hasEffectiveRecipe =
content.PreparedAssetEffectiveRecipeVersion is > 0;
if (hasOverlay != hasBaseRecipe || hasOverlay != hasEffectiveRecipe)
{
throw new SessionConfigurationException(
"process.content overlay path, base recipe, and effective recipe "
+ "must be supplied together.");
}
}
private static void ValidateSession(SessionDescriptor session)

View file

@ -1418,6 +1418,9 @@ public sealed class GameWindow :
new ContentEffectsAudioDependencies(
_datDir,
_options.PreparedAssetPath,
_options.PreparedAssetOverlayPath,
_options.PreparedAssetBaseRecipeVersion,
_options.PreparedAssetEffectiveRecipeVersion,
_options.ResidencyBudgets,
_physicsDataCache,
_animationDiagnostics.DumpMotionEnabled,

View file

@ -116,6 +116,12 @@ public sealed record RuntimeOptions(
/// <see cref="LoginCommands"/>, milliseconds.</summary>
int LoginCommandDelayMs)
{
public string? PreparedAssetOverlayPath { get; init; }
public uint? PreparedAssetBaseRecipeVersion { get; init; }
public uint? PreparedAssetEffectiveRecipeVersion { get; init; }
/// <summary>
/// Build options from the process environment. Used by
/// <c>Program.cs</c> at startup.
@ -272,6 +278,12 @@ public sealed record RuntimeOptions(
{
PreparedAssetPath = NullIfEmpty(content?.PreparedAssetPath)
?? baseOptions.PreparedAssetPath,
PreparedAssetOverlayPath =
NullIfEmpty(content?.PreparedAssetOverlayPath),
PreparedAssetBaseRecipeVersion =
content?.PreparedAssetBaseRecipeVersion,
PreparedAssetEffectiveRecipeVersion =
content?.PreparedAssetEffectiveRecipeVersion,
LiveMode = true,
// Campaign LA gate round 2: a session-config launch IS a product
// launch — the retail UI is the shipped UI, not a dev option.

View file

@ -93,14 +93,24 @@ public readonly record struct PreparedAssetCatalogIdentity(
uint BakeToolVersion)
{
public static PreparedAssetCatalogIdentity From(IDatReaderWriter dats)
=> From(dats, PakFormat.CurrentBakeToolVersion);
public static PreparedAssetCatalogIdentity From(
IDatReaderWriter dats,
uint bakeToolVersion)
{
ArgumentNullException.ThrowIfNull(dats);
if (bakeToolVersion == 0)
{
throw new ArgumentOutOfRangeException(nameof(bakeToolVersion));
}
return new(
checked((uint)dats.PortalIteration),
checked((uint)dats.CellIteration),
checked((uint)dats.HighResIteration),
checked((uint)dats.LanguageIteration),
PakFormat.CurrentBakeToolVersion);
bakeToolVersion);
}
}

View file

@ -0,0 +1,239 @@
using AcDream.Content.Pak;
using AcDream.Core.Physics;
namespace AcDream.Content;
/// <summary>
/// One cumulative overlay in front of one complete base package. Missing keys
/// fall through; an overlay key that exists but is corrupt is authoritative and
/// never hides its corruption behind older base bytes. Render and collision
/// payloads use the exact same rule and the two package owners are disposed as
/// one content set.
/// </summary>
public sealed class LayeredPreparedAssetSource :
IPreparedAssetSource,
IPreparedCollisionSource
{
private IPreparedAssetSource? _baseAssets;
private IPreparedAssetSource? _overlayAssets;
private IPreparedCollisionSource? _baseCollision;
private IPreparedCollisionSource? _overlayCollision;
public LayeredPreparedAssetSource(
IPreparedAssetSource baseSource,
IPreparedAssetSource overlaySource)
{
ArgumentNullException.ThrowIfNull(baseSource);
ArgumentNullException.ThrowIfNull(overlaySource);
if (ReferenceEquals(baseSource, overlaySource))
{
throw new ArgumentException(
"The base and overlay must have independent owners.",
nameof(overlaySource));
}
_baseCollision = baseSource as IPreparedCollisionSource
?? throw new ArgumentException(
"The base source must expose prepared collision payloads.",
nameof(baseSource));
_overlayCollision = overlaySource as IPreparedCollisionSource
?? throw new ArgumentException(
"The overlay source must expose prepared collision payloads.",
nameof(overlaySource));
_baseAssets = baseSource;
_overlayAssets = overlaySource;
}
public PreparedAssetSourceStats Stats
{
get
{
IPreparedAssetSource baseSource = Require(_baseAssets);
IPreparedAssetSource overlay = Require(_overlayAssets);
PreparedAssetSourceStats left = baseSource.Stats;
PreparedAssetSourceStats right = overlay.Stats;
return new(
left.Probes + right.Probes,
left.Reads + right.Reads,
left.Loaded + right.Loaded,
left.Missing + right.Missing,
left.Corrupt + right.Corrupt);
}
}
public PreparedCollisionSourceStats CollisionStats
{
get
{
IPreparedCollisionSource baseSource = Require(_baseCollision);
IPreparedCollisionSource overlay = Require(_overlayCollision);
PreparedCollisionSourceStats left = baseSource.CollisionStats;
PreparedCollisionSourceStats right = overlay.CollisionStats;
return new(
left.Probes + right.Probes,
left.Reads + right.Reads,
left.Loaded + right.Loaded,
left.Missing + right.Missing,
left.Corrupt + right.Corrupt);
}
}
public CacheStats DecodedTextureCacheStats
{
get
{
CacheStats left = Require(_baseAssets).DecodedTextureCacheStats;
CacheStats right = Require(_overlayAssets).DecodedTextureCacheStats;
return new(
left.Hits + right.Hits,
left.Misses + right.Misses,
left.Evictions + right.Evictions);
}
}
public long MappedVirtualBytes =>
checked(
Require(_baseAssets).MappedVirtualBytes
+ Require(_overlayAssets).MappedVirtualBytes);
public PreparedAssetPresence Probe(PakAssetType type, uint sourceFileId)
{
PreparedAssetPresence overlay =
Require(_overlayAssets).Probe(type, sourceFileId);
return overlay == PreparedAssetPresence.Missing
? Require(_baseAssets).Probe(type, sourceFileId)
: overlay;
}
public PreparedAssetReadResult Read(
in PreparedAssetRequest request,
CancellationToken cancellationToken = default)
{
PreparedAssetReadResult overlay =
Require(_overlayAssets).Read(request, cancellationToken);
return overlay.Status == PreparedAssetReadStatus.Missing
? Require(_baseAssets).Read(request, cancellationToken)
: overlay;
}
public PreparedAssetPresence ProbeCollision(
PakAssetType type,
uint sourceFileId)
{
PreparedAssetPresence overlay =
Require(_overlayCollision).ProbeCollision(type, sourceFileId);
return overlay == PreparedAssetPresence.Missing
? Require(_baseCollision).ProbeCollision(type, sourceFileId)
: overlay;
}
public PreparedCollisionReadResult<FlatGfxObjCollisionAsset>
ReadGfxObjCollision(
uint sourceFileId,
CancellationToken cancellationToken = default)
{
PreparedCollisionReadResult<FlatGfxObjCollisionAsset> overlay =
Require(_overlayCollision).ReadGfxObjCollision(
sourceFileId,
cancellationToken);
return overlay.Status == PreparedAssetReadStatus.Missing
? Require(_baseCollision).ReadGfxObjCollision(
sourceFileId,
cancellationToken)
: overlay;
}
public PreparedCollisionReadResult<FlatSetupCollision>
ReadSetupCollision(
uint sourceFileId,
CancellationToken cancellationToken = default)
{
PreparedCollisionReadResult<FlatSetupCollision> overlay =
Require(_overlayCollision).ReadSetupCollision(
sourceFileId,
cancellationToken);
return overlay.Status == PreparedAssetReadStatus.Missing
? Require(_baseCollision).ReadSetupCollision(
sourceFileId,
cancellationToken)
: overlay;
}
public PreparedCollisionReadResult<FlatCellStructureCollisionAsset>
ReadCellStructureCollision(
uint sourceFileId,
CancellationToken cancellationToken = default)
{
PreparedCollisionReadResult<FlatCellStructureCollisionAsset> overlay =
Require(_overlayCollision).ReadCellStructureCollision(
sourceFileId,
cancellationToken);
return overlay.Status == PreparedAssetReadStatus.Missing
? Require(_baseCollision).ReadCellStructureCollision(
sourceFileId,
cancellationToken)
: overlay;
}
public PreparedCollisionReadResult<FlatEnvCellTopology>
ReadEnvCellTopology(
uint sourceFileId,
CancellationToken cancellationToken = default)
{
PreparedCollisionReadResult<FlatEnvCellTopology> overlay =
Require(_overlayCollision).ReadEnvCellTopology(
sourceFileId,
cancellationToken);
return overlay.Status == PreparedAssetReadStatus.Missing
? Require(_baseCollision).ReadEnvCellTopology(
sourceFileId,
cancellationToken)
: overlay;
}
public void Dispose()
{
IPreparedAssetSource? overlay = Interlocked.Exchange(
ref _overlayAssets,
null);
IPreparedAssetSource? baseSource = Interlocked.Exchange(
ref _baseAssets,
null);
_overlayCollision = null;
_baseCollision = null;
List<Exception>? failures = null;
DisposeOne(overlay, ref failures);
DisposeOne(baseSource, ref failures);
if (failures is { Count: > 0 })
{
throw new AggregateException(
"One or more prepared-content layers failed to dispose.",
failures);
}
}
private static T Require<T>(T? value)
where T : class =>
value ?? throw new ObjectDisposedException(
nameof(LayeredPreparedAssetSource));
private static void DisposeOne(
IDisposable? value,
ref List<Exception>? failures)
{
if (value is null)
{
return;
}
try
{
value.Dispose();
}
catch (Exception exception)
{
(failures ??= []).Add(exception);
}
}
}

View file

@ -27,6 +27,12 @@ internal sealed class HeadlessContentDescriptor
[JsonRequired]
public string PreparedAssetPath { get; init; } = string.Empty;
public string? PreparedAssetOverlayPath { get; init; }
public uint? PreparedAssetBaseRecipeVersion { get; init; }
public uint? PreparedAssetEffectiveRecipeVersion { get; init; }
}
// MF-1 (Campaign OP OP7 review fix, 2026-08-11): record, not class — the

View file

@ -173,6 +173,18 @@ internal static class HeadlessConfigurationLoader
throw new HeadlessConfigurationException(
"process.content requires non-empty datDirectory and preparedAssetPath.");
}
bool hasOverlay = !string.IsNullOrWhiteSpace(
content.PreparedAssetOverlayPath);
bool hasBaseRecipe = content.PreparedAssetBaseRecipeVersion is > 0;
bool hasEffectiveRecipe =
content.PreparedAssetEffectiveRecipeVersion is > 0;
if (hasOverlay != hasBaseRecipe || hasOverlay != hasEffectiveRecipe)
{
throw new HeadlessConfigurationException(
"process.content overlay path, base recipe, and effective recipe "
+ "must be supplied together.");
}
}
private static void ValidateSession(

View file

@ -43,15 +43,56 @@ internal sealed class ProductionHeadlessProcessContentFactory
string datDirectory = Path.GetFullPath(descriptor.DatDirectory);
string preparedAssetPath =
Path.GetFullPath(descriptor.PreparedAssetPath);
string? overlayPath = string.IsNullOrWhiteSpace(
descriptor.PreparedAssetOverlayPath)
? null
: Path.GetFullPath(descriptor.PreparedAssetOverlayPath);
IDatReaderWriter? dats = null;
IPreparedAssetSource? prepared = null;
try
{
dats = RuntimeDatCollectionFactory.OpenReadOnly(datDirectory);
prepared = new PakPreparedAssetSource(
preparedAssetPath,
dats,
diagnostic);
if (overlayPath is null)
{
prepared = new PakPreparedAssetSource(
preparedAssetPath,
dats,
diagnostic);
}
else
{
if (descriptor.PreparedAssetBaseRecipeVersion is not > 0
|| descriptor.PreparedAssetEffectiveRecipeVersion
!= AcDream.Content.Pak.PakFormat.CurrentBakeToolVersion)
{
throw new InvalidDataException(
"Layered prepared content does not match the client's recipe.");
}
var baseSource = new PakPreparedAssetSource(
preparedAssetPath,
PreparedAssetCatalogIdentity.From(
dats,
descriptor.PreparedAssetBaseRecipeVersion.Value),
diagnostic);
try
{
var overlaySource = new PakPreparedAssetSource(
overlayPath,
PreparedAssetCatalogIdentity.From(
dats,
descriptor.PreparedAssetEffectiveRecipeVersion.Value),
diagnostic);
prepared = new LayeredPreparedAssetSource(
baseSource,
overlaySource);
}
catch
{
baseSource.Dispose();
throw;
}
}
MagicCatalog magic = MagicCatalog.Load(dats);
Region region = dats.Get<Region>(0x13000000u)
?? throw new InvalidOperationException(

View file

@ -11,18 +11,43 @@ public sealed record BakeProcessRequest(
string DatDirectory,
string OutputPath,
int Threads,
string? PublicationNonce = null)
string? PublicationNonce = null,
IReadOnlyList<uint>? DatIds = null,
IReadOnlyList<byte>? Landblocks = null)
{
public IReadOnlyList<string> Arguments =>
[
"--dat-dir",
DatDirectory,
"--out",
OutputPath,
"--threads",
Threads.ToString(CultureInfo.InvariantCulture),
"--progress-json",
];
public IReadOnlyList<string> Arguments
{
get
{
var arguments = new List<string>
{
"--dat-dir",
DatDirectory,
"--out",
OutputPath,
"--threads",
Threads.ToString(CultureInfo.InvariantCulture),
"--progress-json",
};
if (DatIds is { Count: > 0 })
{
arguments.Add("--ids");
arguments.Add(string.Join(
',',
DatIds.Select(static id => $"0x{id:X8}")));
}
if (Landblocks is { Count: > 0 })
{
arguments.Add("--landblocks");
arguments.Add(string.Join(
',',
Landblocks.Select(static id => $"0x{id:X2}")));
}
return arguments;
}
}
}
public sealed record BakeProcessResult(int ExitCode, string StandardError);

View file

@ -0,0 +1,127 @@
namespace AcDream.Launcher.Core.Installation;
/// <summary>The only four prepared-content actions exposed to the launcher UI.</summary>
public enum ContentWorkKind
{
None,
Overlay,
FullRebuild,
Verify,
}
/// <summary>
/// One resolved recipe migration. Overlay ids are acdream-bake's existing
/// hexadecimal DAT-id filters; landblocks use its existing 8-bit hexadecimal
/// landblock filter. A plan is deliberately data-only so update orchestration
/// and the UI do not need to understand extraction algorithms.
/// </summary>
public sealed record ContentMigrationPlan(
uint FromRecipeVersion,
uint TargetRecipeVersion,
ContentWorkKind Kind,
string Reason,
IReadOnlyList<uint>? DatIds = null,
IReadOnlyList<byte>? Landblocks = null)
{
public IReadOnlyList<uint> EffectiveDatIds => DatIds ?? [];
public IReadOnlyList<byte> EffectiveLandblocks => Landblocks ?? [];
}
/// <summary>
/// Compiled content-recipe ledger. Launcher and client ship together, so the
/// updated launcher always knows how to prepare the matching client's recipe
/// without changing the strict release-feed schema.
/// </summary>
public static class ContentMigrationCatalog
{
private static readonly IReadOnlyDictionary<uint, ContentMigrationPlan> Steps =
new Dictionary<uint, ContentMigrationPlan>
{
[2] = FullRebuild(1, 2, "prepared EnvCell identity changed"),
[3] = FullRebuild(2, 3, "render-pass translucency moved into prepared meshes"),
[4] = FullRebuild(3, 4, "flat collision and EnvCell topology were added"),
[5] = FullRebuild(
4,
5,
"solid-colour positive mesh faces must be regenerated"),
};
public static ContentMigrationPlan Resolve(uint fromRecipeVersion, uint targetRecipeVersion)
{
if (fromRecipeVersion == 0 || targetRecipeVersion == 0)
{
throw new ArgumentOutOfRangeException(
nameof(fromRecipeVersion),
"Content recipe versions must be positive.");
}
if (fromRecipeVersion == targetRecipeVersion)
{
return new ContentMigrationPlan(
fromRecipeVersion,
targetRecipeVersion,
ContentWorkKind.None,
"Prepared content already matches this client.");
}
if (fromRecipeVersion > targetRecipeVersion)
{
throw new InvalidOperationException(
$"Prepared content recipe {fromRecipeVersion} is newer than this "
+ $"launcher's recipe {targetRecipeVersion}.");
}
var ids = new HashSet<uint>();
var landblocks = new HashSet<byte>();
var reasons = new List<string>();
ContentWorkKind combinedKind = ContentWorkKind.None;
for (uint target = checked(fromRecipeVersion + 1);
target <= targetRecipeVersion;
target++)
{
if (!Steps.TryGetValue(target, out ContentMigrationPlan? step)
|| step.FromRecipeVersion != target - 1)
{
throw new InvalidOperationException(
$"No prepared-content migration is published for recipe "
+ $"{target - 1} to {target}.");
}
reasons.Add(step.Reason);
if (step.Kind == ContentWorkKind.FullRebuild)
{
combinedKind = ContentWorkKind.FullRebuild;
}
else if (combinedKind != ContentWorkKind.FullRebuild
&& step.Kind == ContentWorkKind.Overlay)
{
combinedKind = ContentWorkKind.Overlay;
}
foreach (uint id in step.EffectiveDatIds)
{
ids.Add(id);
}
foreach (byte landblock in step.EffectiveLandblocks)
{
landblocks.Add(landblock);
}
}
return new ContentMigrationPlan(
fromRecipeVersion,
targetRecipeVersion,
combinedKind,
string.Join("; ", reasons),
ids.Order().ToArray(),
landblocks.Order().ToArray());
}
private static ContentMigrationPlan FullRebuild(
uint from,
uint target,
string reason) =>
new(from, target, ContentWorkKind.FullRebuild, reason);
}

View file

@ -0,0 +1,413 @@
using System.Buffers.Binary;
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using AcDream.Launcher.Core.Integrity;
using AcDream.Launcher.Core.Launching;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Installation;
public sealed record LauncherContentOverlay(
string Path,
string Sha256,
long Size,
uint RecipeVersion);
public sealed record LauncherContentState(
int SchemaVersion,
string BaseSha256,
uint EffectiveRecipeVersion,
LauncherContentOverlay? Overlay)
{
public const int CurrentSchemaVersion = 1;
}
/// <summary>
/// Optional overlay authority kept beside, rather than inside, install.json.
/// Old launchers safely ignore this file instead of rejecting a new field in
/// their strict install-record schema.
/// </summary>
public sealed class LauncherContentStateStore
{
private const uint PakMagic = 0x4B504341u;
private const uint PakFormatVersion = 1;
private const int PakHeaderSize = 64;
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
};
private readonly string _pakDirectory;
private readonly Func<string, CancellationToken, Task<string>> _computeSha256;
public LauncherContentStateStore(
ApplicationPathSet paths,
Func<string, CancellationToken, Task<string>>? computeSha256 = null)
{
ArgumentNullException.ThrowIfNull(paths);
_pakDirectory = Path.Combine(
Path.GetFullPath(paths.DataDirectory),
"pak");
_computeSha256 = computeSha256
?? ((path, cancellationToken) =>
FileIntegrity.ComputeSha256HexAsync(path, cancellationToken));
}
public string StatePath => Path.Combine(_pakDirectory, "content.current.json");
public string ClientCompatibilityPendingPath => Path.Combine(
_pakDirectory,
"content.client-pending");
public string OverlayCandidatePath => Path.Combine(
_pakDirectory,
".acdream-update.candidate.pak");
public bool IsClientCompatibilityPending =>
File.Exists(ClientCompatibilityPendingPath);
public void MarkClientCompatibilityPending()
{
Directory.CreateDirectory(_pakDirectory);
string temporaryPath = ClientCompatibilityPendingPath + ".tmp";
File.WriteAllText(
temporaryPath,
LauncherInstallRecordStore.CurrentBakeToolVersion.ToString(
CultureInfo.InvariantCulture));
File.Move(
temporaryPath,
ClientCompatibilityPendingPath,
overwrite: true);
}
public void ClearClientCompatibilityPending()
{
LauncherInstallRecordStore.TryDelete(ClientCompatibilityPendingPath);
LauncherInstallRecordStore.TryDelete(
ClientCompatibilityPendingPath + ".tmp");
}
public string GetOverlayPath(LauncherContentOverlay overlay)
{
ArgumentNullException.ThrowIfNull(overlay);
string? error = ValidateOverlayFileName(overlay.Path);
if (error is not null)
{
throw new InvalidDataException(error);
}
return Path.Combine(_pakDirectory, overlay.Path);
}
public async Task<(LauncherContentState? State, string? Error)> LoadAsync(
LauncherInstallRecord baseRecord,
bool forceFullVerification = false,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(baseRecord);
if (!File.Exists(StatePath))
{
return (null, null);
}
LauncherContentState? state;
try
{
await using FileStream stream = new(
StatePath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
4096,
FileOptions.Asynchronous | FileOptions.SequentialScan);
state = await JsonSerializer.DeserializeAsync<LauncherContentState>(
stream,
SerializerOptions,
cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex) when (ex is IOException
or UnauthorizedAccessException
or JsonException
or NotSupportedException)
{
return (null, $"The prepared-content update record could not be read: {ex.Message}");
}
string? contractError = ValidateContract(baseRecord, state);
if (contractError is not null)
{
return (null, contractError);
}
LauncherContentOverlay overlay = state!.Overlay!;
string overlayPath = GetOverlayPath(overlay);
if (!File.Exists(baseRecord.PreparedAssetPath))
{
return (null, "The base prepared package is missing.");
}
if (new FileInfo(baseRecord.PreparedAssetPath).Length
!= baseRecord.PreparedAssetSize)
{
return (null, "The base prepared package size changed.");
}
if (!File.Exists(overlayPath))
{
return (null, "The prepared-content overlay is missing.");
}
if (new FileInfo(overlayPath).Length != overlay.Size)
{
return (null, "The prepared-content overlay size changed.");
}
try
{
PakIdentity baseIdentity = ReadPakIdentity(baseRecord.PreparedAssetPath);
PakIdentity overlayIdentity = ReadPakIdentity(overlayPath);
if (baseIdentity.FormatVersion != PakFormatVersion
|| overlayIdentity.FormatVersion != PakFormatVersion)
{
return (null, "The base or overlay pak format is not supported.");
}
if (baseIdentity.RecipeVersion != baseRecord.BakeToolVersion
|| overlayIdentity.RecipeVersion != overlay.RecipeVersion)
{
return (null, "The base or overlay content recipe does not match its record.");
}
if (!baseIdentity.SameDatSet(overlayIdentity))
{
return (null, "The prepared-content overlay was built from a different DAT set.");
}
}
catch (Exception ex) when (ex is IOException
or UnauthorizedAccessException
or InvalidDataException)
{
return (null, $"The prepared-content package header is invalid: {ex.Message}");
}
if (forceFullVerification)
{
string baseSha = await _computeSha256(
baseRecord.PreparedAssetPath,
cancellationToken)
.ConfigureAwait(false);
if (!FileIntegrity.Matches(baseSha, baseRecord.PreparedAssetSha256))
{
return (null, "The base prepared package SHA-256 does not match its record.");
}
string overlaySha = await _computeSha256(overlayPath, cancellationToken)
.ConfigureAwait(false);
if (!FileIntegrity.Matches(overlaySha, overlay.Sha256))
{
return (null, "The prepared-content overlay SHA-256 does not match its record.");
}
}
return (state, null);
}
public async Task SaveAtomicallyAsync(
LauncherInstallRecord baseRecord,
LauncherContentState state,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(baseRecord);
ArgumentNullException.ThrowIfNull(state);
string? error = ValidateContract(baseRecord, state);
if (error is not null)
{
throw new InvalidDataException(error);
}
string overlayPath = GetOverlayPath(state.Overlay!);
string? candidateError = ValidateCandidate(
baseRecord,
overlayPath,
state.Overlay!);
if (candidateError is not null)
{
throw new InvalidDataException(candidateError);
}
Directory.CreateDirectory(_pakDirectory);
string temporaryPath = StatePath + $".{Guid.NewGuid():N}.tmp";
try
{
await using (FileStream stream = new(
temporaryPath,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
4096,
FileOptions.Asynchronous | FileOptions.WriteThrough))
{
await JsonSerializer.SerializeAsync(
stream,
state,
SerializerOptions,
cancellationToken)
.ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
stream.Flush(flushToDisk: true);
}
cancellationToken.ThrowIfCancellationRequested();
File.Move(temporaryPath, StatePath, overwrite: true);
}
finally
{
LauncherInstallRecordStore.TryDelete(temporaryPath);
}
}
public void Delete() => LauncherInstallRecordStore.TryDelete(StatePath);
public string? ValidateCandidate(
LauncherInstallRecord baseRecord,
string overlayPath,
LauncherContentOverlay overlay)
{
ArgumentNullException.ThrowIfNull(baseRecord);
ArgumentException.ThrowIfNullOrWhiteSpace(overlayPath);
ArgumentNullException.ThrowIfNull(overlay);
if (!File.Exists(baseRecord.PreparedAssetPath))
{
return "The base prepared package is missing.";
}
if (!File.Exists(overlayPath)
|| new FileInfo(overlayPath).Length != overlay.Size)
{
return "The prepared-content overlay candidate size changed.";
}
try
{
PakIdentity baseIdentity = ReadPakIdentity(baseRecord.PreparedAssetPath);
PakIdentity overlayIdentity = ReadPakIdentity(overlayPath);
if (baseIdentity.FormatVersion != PakFormatVersion
|| overlayIdentity.FormatVersion != PakFormatVersion
|| baseIdentity.RecipeVersion != baseRecord.BakeToolVersion
|| overlayIdentity.RecipeVersion != overlay.RecipeVersion
|| !baseIdentity.SameDatSet(overlayIdentity))
{
return "The prepared-content overlay candidate header does not "
+ "match the base pak and requested recipe.";
}
}
catch (Exception ex) when (ex is IOException
or UnauthorizedAccessException
or InvalidDataException)
{
return $"The prepared-content overlay candidate is invalid: {ex.Message}";
}
return null;
}
private static string? ValidateContract(
LauncherInstallRecord baseRecord,
LauncherContentState? state)
{
if (state is null)
{
return "The prepared-content update record is empty.";
}
if (state.SchemaVersion != LauncherContentState.CurrentSchemaVersion)
{
return $"Prepared-content record version {state.SchemaVersion} is not supported.";
}
if (!IsSha256(state.BaseSha256)
|| !FileIntegrity.Matches(state.BaseSha256, baseRecord.PreparedAssetSha256))
{
return "The prepared-content update record does not match the installed base pak.";
}
if (state.Overlay is null)
{
return "The prepared-content update record is missing its overlay.";
}
if (state.EffectiveRecipeVersion != state.Overlay.RecipeVersion
|| state.EffectiveRecipeVersion <= baseRecord.BakeToolVersion)
{
return "The prepared-content overlay recipe is not a newer effective recipe.";
}
if (!IsSha256(state.Overlay.Sha256) || state.Overlay.Size <= 0)
{
return "The prepared-content overlay is missing valid integrity metadata.";
}
return ValidateOverlayFileName(state.Overlay.Path);
}
private static string? ValidateOverlayFileName(string path)
{
if (string.IsNullOrWhiteSpace(path)
|| Path.IsPathFullyQualified(path)
|| !string.Equals(path, Path.GetFileName(path), StringComparison.Ordinal)
|| path is "." or ".."
|| !path.EndsWith(".pak", StringComparison.OrdinalIgnoreCase))
{
return "The prepared-content overlay path must be one pak filename beneath the pak directory.";
}
return null;
}
private static PakIdentity ReadPakIdentity(string path)
{
Span<byte> header = stackalloc byte[PakHeaderSize];
using FileStream stream = new(path, FileMode.Open, FileAccess.Read, FileShare.Read);
stream.ReadExactly(header);
uint magic = BinaryPrimitives.ReadUInt32LittleEndian(header[0..4]);
if (magic != PakMagic)
{
throw new InvalidDataException("pak magic does not match ACPK");
}
return new PakIdentity(
BinaryPrimitives.ReadUInt32LittleEndian(header[4..8]),
BinaryPrimitives.ReadUInt32LittleEndian(header[8..12]),
BinaryPrimitives.ReadUInt32LittleEndian(header[12..16]),
BinaryPrimitives.ReadUInt32LittleEndian(header[16..20]),
BinaryPrimitives.ReadUInt32LittleEndian(header[20..24]),
BinaryPrimitives.ReadUInt32LittleEndian(header[36..40]));
}
private static bool IsSha256(string value) =>
value.Length == 64 && value.All(Uri.IsHexDigit);
private readonly record struct PakIdentity(
uint FormatVersion,
uint PortalIteration,
uint CellIteration,
uint HighResIteration,
uint LanguageIteration,
uint RecipeVersion)
{
public bool SameDatSet(PakIdentity other) =>
PortalIteration == other.PortalIteration
&& CellIteration == other.CellIteration
&& HighResIteration == other.HighResIteration
&& LanguageIteration == other.LanguageIteration;
}
}

View file

@ -10,15 +10,20 @@ public enum InstallRecordVerificationState
{
Missing,
Verified,
ContentUpdateRequired,
Invalid,
}
public sealed record InstallRecordVerification(
InstallRecordVerificationState State,
LauncherInstallRecord? Record,
string Status)
string Status,
ContentMigrationPlan? RequiredContentWork = null)
{
public bool IsVerified => State == InstallRecordVerificationState.Verified;
public bool RequiresContentUpdate =>
State == InstallRecordVerificationState.ContentUpdateRequired;
}
/// <summary>
@ -77,7 +82,8 @@ public sealed class LauncherInstallRecordStore
/// passes false.</param>
public async Task<InstallRecordVerification> LoadAndVerifyAsync(
CancellationToken cancellationToken = default,
bool forceFullVerification = false)
bool forceFullVerification = false,
IProgress<string>? progress = null)
{
await using InstallerTransactionLease lease =
await InstallerTransactionLease.AcquireAsync(
@ -86,13 +92,15 @@ public sealed class LauncherInstallRecordStore
.ConfigureAwait(false);
return await LoadAndVerifyUnderLeaseAsync(
cancellationToken,
forceFullVerification)
forceFullVerification,
progress)
.ConfigureAwait(false);
}
internal async Task<InstallRecordVerification> LoadAndVerifyUnderLeaseAsync(
CancellationToken cancellationToken = default,
bool forceFullVerification = false)
bool forceFullVerification = false,
IProgress<string>? progress = null)
{
if (!File.Exists(RecordPath))
{
@ -146,18 +154,59 @@ public sealed class LauncherInstallRecordStore
string? contractError = ValidateRecordContract(
record,
requireCanonicalSerializedPaths: true);
requireCanonicalSerializedPaths: true,
requireCurrentRecipe: false);
if (contractError is not null)
{
return Invalid(contractError);
}
if (record.BakeToolVersion > CurrentBakeToolVersion)
{
return Invalid(
$"Prepared content recipe {record.BakeToolVersion} is newer than "
+ $"this launcher's recipe {CurrentBakeToolVersion}. Update the launcher.");
}
if (record.BakeToolVersion < CurrentBakeToolVersion)
{
if (!File.Exists(record.PreparedAssetPath))
{
return Invalid("The prepared package is missing.");
}
if (new FileInfo(record.PreparedAssetPath).Length
!= record.PreparedAssetSize)
{
return Invalid("The prepared package size changed.");
}
ContentMigrationPlan plan;
try
{
plan = ContentMigrationCatalog.Resolve(
record.BakeToolVersion,
CurrentBakeToolVersion);
}
catch (InvalidOperationException ex)
{
return Invalid(ex.Message);
}
return new InstallRecordVerification(
InstallRecordVerificationState.ContentUpdateRequired,
record,
$"World data update required: {plan.Reason}.",
plan);
}
string backupPath = GetBackupPath(record.PreparedAssetPath);
FileVerification current = await VerifyFileAsync(
record.PreparedAssetPath,
record,
cancellationToken,
allowCachedResult: !forceFullVerification)
allowCachedResult: !forceFullVerification,
progress)
.ConfigureAwait(false);
if (current.IsValid)
{
@ -176,7 +225,8 @@ public sealed class LauncherInstallRecordStore
backupPath,
record,
cancellationToken,
allowCachedResult: false)
allowCachedResult: false,
progress)
.ConfigureAwait(false);
if (backup.IsValid)
{
@ -220,7 +270,8 @@ public sealed class LauncherInstallRecordStore
LauncherInstallRecord normalized = NormalizeForSave(record);
string? contractError = ValidateRecordContract(
normalized,
requireCanonicalSerializedPaths: true);
requireCanonicalSerializedPaths: true,
requireCurrentRecipe: true);
if (contractError is not null)
{
throw new InvalidDataException(contractError);
@ -263,9 +314,37 @@ public sealed class LauncherInstallRecordStore
}
}
/// <summary>Records the hash the installer just computed so the first
/// launch after a successful bake does not immediately hash the same
/// multi-gigabyte file again.</summary>
internal void RememberVerifiedPackage(LauncherInstallRecord record)
{
ArgumentNullException.ThrowIfNull(record);
try
{
var file = new FileInfo(record.PreparedAssetPath);
if (file.Exists && file.Length == record.PreparedAssetSize)
{
_verificationCache.Write(
record.PreparedAssetPath,
file.Length,
file.LastWriteTimeUtc,
record.PreparedAssetSha256);
}
}
catch (Exception ex) when (ex is IOException
or UnauthorizedAccessException
or ArgumentException
or NotSupportedException)
{
// The cache is only an optimization. Startup will hash visibly.
}
}
private string? ValidateRecordContract(
LauncherInstallRecord record,
bool requireCanonicalSerializedPaths)
bool requireCanonicalSerializedPaths,
bool requireCurrentRecipe)
{
if (record.Version != LauncherInstallRecord.CurrentRecordVersion)
{
@ -277,7 +356,8 @@ public sealed class LauncherInstallRecordStore
return "The install record is missing SHA-256, size, or bake-tool metadata.";
}
if (record.BakeToolVersion != CurrentBakeToolVersion)
if (requireCurrentRecipe
&& record.BakeToolVersion != CurrentBakeToolVersion)
{
return $"Bake tool version {record.BakeToolVersion} is not supported; "
+ $"version {CurrentBakeToolVersion} is required.";
@ -422,7 +502,8 @@ public sealed class LauncherInstallRecordStore
string path,
LauncherInstallRecord record,
CancellationToken cancellationToken,
bool allowCachedResult)
bool allowCachedResult,
IProgress<string>? progress)
{
if (!File.Exists(path))
{
@ -453,6 +534,12 @@ public sealed class LauncherInstallRecordStore
return new FileVerification(true, "Client content verified.");
}
progress?.Report(
allowCachedResult
? "The verification cache is missing or changed. Reading the "
+ "whole world-data pak once; this can take around 30 seconds."
: "Reading the whole world-data pak for explicit verification; "
+ "this can take around 30 seconds.");
string sha256 = await _computeSha256(path, cancellationToken)
.ConfigureAwait(false);
if (!FileIntegrity.Matches(sha256, record.PreparedAssetSha256))

View file

@ -61,25 +61,55 @@ public interface ILauncherInstaller
CancellationToken cancellationToken = default,
bool forceFullVerification = false);
Task<InstallRecordVerification> LoadExistingWithProgressAsync(
CancellationToken cancellationToken = default,
bool forceFullVerification = false,
IProgress<string>? progress = null) =>
LoadExistingAsync(cancellationToken, forceFullVerification);
Task<LauncherInstallResult> InstallAsync(
string datDirectory,
int threads,
IProgress<LauncherInstallProgress>? progress = null,
CancellationToken cancellationToken = default);
Task<LauncherInstallResult> ApplyContentUpdateAsync(
string datDirectory,
int threads,
ContentMigrationPlan migration,
IProgress<LauncherInstallProgress>? progress = null,
CancellationToken cancellationToken = default) =>
migration.Kind == ContentWorkKind.FullRebuild
? InstallAsync(
datDirectory,
threads,
progress,
cancellationToken)
: Task.FromException<LauncherInstallResult>(
new NotSupportedException(
"This installer does not support filtered content overlays."));
/// <summary>Clears the crash-safe gate left by a completed content
/// migration after the active client has been confirmed compatible.</summary>
void ConfirmClientCompatibility()
{
}
}
/// <summary>
/// BCL-only first-run transaction. It invokes the GL-free bake executable as
/// a child, consumes only its versioned JSONL records, verifies the published
/// pak, and atomically records the install. A prior verified package is moved
/// to an adjacent recovery slot and restored on every failure/cancellation
/// path, so a fake or crashed child cannot replace it with partial output.
/// pak, and atomically records the install. Long full rebuilds and filtered
/// overlays are written beside active content; the old package is touched only
/// during the final verified publication, so cancellation and child failure
/// leave the playable bytes in place.
/// </summary>
public sealed class LauncherInstaller : ILauncherInstaller
{
private readonly string _bakeExecutablePath;
private readonly DatDirectoryLocator _datDirectories;
private readonly LauncherInstallRecordStore _recordStore;
private readonly LauncherContentStateStore _contentStateStore;
private readonly IBakeProcessRunner _processRunner;
private readonly Func<string, CancellationToken, Task<string>> _computeSha256;
private readonly SemaphoreSlim _installGate = new(1, 1);
@ -96,6 +126,7 @@ public sealed class LauncherInstaller : ILauncherInstaller
string bakeExecutablePath,
DatDirectoryLocator? datDirectories = null,
LauncherInstallRecordStore? recordStore = null,
LauncherContentStateStore? contentStateStore = null,
IBakeProcessRunner? processRunner = null,
Func<string, CancellationToken, Task<string>>? computeSha256 = null)
{
@ -111,6 +142,8 @@ public sealed class LauncherInstaller : ILauncherInstaller
paths,
_datDirectories,
_computeSha256);
_contentStateStore = contentStateStore
?? new LauncherContentStateStore(paths, _computeSha256);
_processRunner = processRunner ?? new SystemBakeProcessRunner();
}
@ -120,9 +153,21 @@ public sealed class LauncherInstaller : ILauncherInstaller
public DatDirectoryValidation ValidateDatDirectory(string? directory) =>
_datDirectories.Validate(directory);
public void ConfirmClientCompatibility() =>
_contentStateStore.ClearClientCompatibilityPending();
public async Task<InstallRecordVerification> LoadExistingAsync(
CancellationToken cancellationToken = default,
bool forceFullVerification = false)
bool forceFullVerification = false) =>
await LoadExistingWithProgressAsync(
cancellationToken,
forceFullVerification)
.ConfigureAwait(false);
public async Task<InstallRecordVerification> LoadExistingWithProgressAsync(
CancellationToken cancellationToken = default,
bool forceFullVerification = false,
IProgress<string>? progress = null)
{
await _installGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
@ -136,8 +181,28 @@ public sealed class LauncherInstaller : ILauncherInstaller
InstallRecordVerification verification =
await RecoverExistingUnderPublicationGuardAsync(
cancellationToken,
forceFullVerification)
forceFullVerification,
progress)
.ConfigureAwait(false);
verification = await ResolveContentStateAsync(
verification,
forceFullVerification,
cancellationToken)
.ConfigureAwait(false);
if (verification.IsVerified
&& verification.Record is not null
&& _contentStateStore.IsClientCompatibilityPending)
{
verification = verification with
{
Record = verification.Record with
{
RequiresClientCompatibilityConfirmation = true,
},
Status = "World data is verified; matching client confirmation is pending.",
};
}
_verifiedRecord = verification.Record;
return verification;
}
@ -210,6 +275,7 @@ public sealed class LauncherInstaller : ILauncherInstaller
}
string outputPath = _recordStore.PreparedAssetPath;
string bakeOutputPath = GetFullRebuildCandidatePath(outputPath);
string backupPath = LauncherInstallRecordStore.GetBackupPath(outputPath);
InstallRecordVerification existing =
await RecoverExistingUnderPublicationGuardAsync(
@ -221,6 +287,15 @@ public sealed class LauncherInstaller : ILauncherInstaller
forceFullVerification: true)
.ConfigureAwait(false);
_verifiedRecord = existing.Record;
LauncherContentState? priorContentState = null;
if (existing.Record is not null)
{
(priorContentState, _) = await _contentStateStore.LoadAsync(
existing.Record,
forceFullVerification: false,
cancellationToken)
.ConfigureAwait(false);
}
Directory.CreateDirectory(
Path.GetDirectoryName(outputPath)
@ -230,12 +305,11 @@ public sealed class LauncherInstaller : ILauncherInstaller
Report(
progress,
LauncherInstallPhase.PreparingOutput,
"Preparing the atomic package transaction...");
bool previousPreserved = PreservePreviousPackage(outputPath, backupPath);
if (!previousPreserved)
{
LauncherInstallRecordStore.TryDelete(backupPath);
}
"Preparing a replacement beside the active package...");
LauncherInstallRecordStore.TryDelete(bakeOutputPath);
LauncherInstallRecordStore.TryDelete(backupPath);
bool previousPreserved = false;
bool canonicalReplaced = false;
var parser = new BakeProgressJsonlParser();
var protocol = new BakeProgressProtocol();
@ -289,12 +363,12 @@ public sealed class LauncherInstaller : ILauncherInstaller
await using (
BakePublicationGuardContract.PublicationLease publication =
await BakePublicationGuardContract.AcquireAsync(
outputPath,
bakeOutputPath,
cancellationToken)
.ConfigureAwait(false))
{
BakePublicationGuardContract.Authorize(
outputPath,
bakeOutputPath,
publicationNonce,
publication);
}
@ -302,7 +376,7 @@ public sealed class LauncherInstaller : ILauncherInstaller
var request = new BakeProcessRequest(
_bakeExecutablePath,
validation.Directory,
outputPath,
bakeOutputPath,
threads,
publicationNonce);
BakeProcessResult processResult = await _processRunner.RunAsync(
@ -368,13 +442,13 @@ public sealed class LauncherInstaller : ILauncherInstaller
$"The bake completed with {completed.Failures:N0} failed assets.");
}
if (!File.Exists(outputPath))
if (!File.Exists(bakeOutputPath))
{
throw new LauncherInstallException(
"The bake tool reported success but did not publish acdream.pak.");
}
long size = new FileInfo(outputPath).Length;
long size = new FileInfo(bakeOutputPath).Length;
if (size <= 0 || size != completed.OutputBytes)
{
throw new LauncherInstallException(
@ -385,7 +459,7 @@ public sealed class LauncherInstaller : ILauncherInstaller
progress,
LauncherInstallPhase.VerifyingPackage,
"Computing the prepared package SHA-256...");
string sha256 = await _computeSha256(outputPath, cancellationToken)
string sha256 = await _computeSha256(bakeOutputPath, cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
@ -398,15 +472,31 @@ public sealed class LauncherInstaller : ILauncherInstaller
Report(
progress,
LauncherInstallPhase.SavingRecord,
"Saving the verified install record...");
"Activating the verified package...");
previousPreserved = PreservePreviousPackage(outputPath, backupPath);
File.Move(bakeOutputPath, outputPath, overwrite: true);
canonicalReplaced = true;
await _recordStore.SaveAtomicallyUnderLeaseAsync(
record,
cancellationToken)
.ConfigureAwait(false);
_recordStore.RememberVerifiedPackage(record);
// A complete current-recipe base supersedes every overlay. Publish
// the base record first, then remove the optional sidecar so a
// crash can at worst leave a sidecar whose base digest no longer
// binds and which startup therefore rejects.
_contentStateStore.Delete();
if (priorContentState?.Overlay is not null)
{
LauncherInstallRecordStore.TryDelete(
_contentStateStore.GetOverlayPath(
priorContentState.Overlay));
}
_verifiedRecord = record;
await FinalizeSuccessfulPublicationAsync(
outputPath,
bakeOutputPath,
backupPath,
publicationNonce)
.ConfigureAwait(false);
@ -421,9 +511,11 @@ public sealed class LauncherInstaller : ILauncherInstaller
catch (OperationCanceledException)
{
await FinalizeFailedPublicationAsync(
bakeOutputPath,
outputPath,
backupPath,
previousPreserved,
canonicalReplaced,
publicationNonce)
.ConfigureAwait(false);
Report(
@ -435,9 +527,11 @@ public sealed class LauncherInstaller : ILauncherInstaller
catch (Exception ex)
{
await FinalizeFailedPublicationAsync(
bakeOutputPath,
outputPath,
backupPath,
previousPreserved,
canonicalReplaced,
publicationNonce)
.ConfigureAwait(false);
Report(
@ -456,9 +550,42 @@ public sealed class LauncherInstaller : ILauncherInstaller
private async Task<InstallRecordVerification>
RecoverExistingUnderPublicationGuardAsync(
CancellationToken cancellationToken,
bool forceFullVerification = false)
bool forceFullVerification = false,
IProgress<string>? progress = null)
{
string outputPath = _recordStore.PreparedAssetPath;
string candidatePath = GetFullRebuildCandidatePath(outputPath);
await using (
BakePublicationGuardContract.PublicationLease candidatePublication =
await BakePublicationGuardContract.AcquireAsync(
candidatePath,
cancellationToken,
PublicationLeaseContentionObservedForTest)
.ConfigureAwait(false))
{
BakePublicationGuardContract.Invalidate(
candidatePath,
candidatePublication);
LauncherInstallRecordStore.TryDelete(candidatePath);
BakeOutputStagingContract.DeleteOwnedStagingFiles(candidatePath);
}
string overlayCandidatePath = _contentStateStore.OverlayCandidatePath;
await using (
BakePublicationGuardContract.PublicationLease overlayPublication =
await BakePublicationGuardContract.AcquireAsync(
overlayCandidatePath,
cancellationToken)
.ConfigureAwait(false))
{
BakePublicationGuardContract.Invalidate(
overlayCandidatePath,
overlayPublication);
LauncherInstallRecordStore.TryDelete(overlayCandidatePath);
BakeOutputStagingContract.DeleteOwnedStagingFiles(
overlayCandidatePath);
}
await using BakePublicationGuardContract.PublicationLease publication =
await BakePublicationGuardContract.AcquireAsync(
outputPath,
@ -472,10 +599,473 @@ public sealed class LauncherInstaller : ILauncherInstaller
BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath);
return await _recordStore.LoadAndVerifyUnderLeaseAsync(
cancellationToken,
forceFullVerification)
forceFullVerification,
progress)
.ConfigureAwait(false);
}
public async Task<LauncherInstallResult> ApplyContentUpdateAsync(
string datDirectory,
int threads,
ContentMigrationPlan migration,
IProgress<LauncherInstallProgress>? progress = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(migration);
if (migration.Kind is not ContentWorkKind.FullRebuild
and not ContentWorkKind.Overlay)
{
throw new LauncherInstallException(
$"Content work kind {migration.Kind} cannot build an update.");
}
if (threads <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(threads),
"Bake thread count must be positive.");
}
await _installGate.WaitAsync(cancellationToken).ConfigureAwait(false);
bool compatibilityMarkerPublished = false;
try
{
await using InstallerTransactionLease lease =
await InstallerTransactionLease.AcquireAsync(
_recordStore.DataDirectory,
cancellationToken,
TransactionLeaseContentionObservedForTest)
.ConfigureAwait(false);
// Publish the tiny gate before changing any content bytes. A
// crash can therefore leave an unnecessary confirmation prompt,
// but can never forget a required one after the pak changes.
_contentStateStore.MarkClientCompatibilityPending();
compatibilityMarkerPublished = true;
LauncherInstallResult result = migration.Kind == ContentWorkKind.FullRebuild
? await InstallCoreAsync(
datDirectory,
threads,
progress,
cancellationToken)
.ConfigureAwait(false)
: await InstallOverlayCoreAsync(
datDirectory,
threads,
migration,
progress,
cancellationToken)
.ConfigureAwait(false);
LauncherInstallRecord gatedRecord = result.Record with
{
RequiresClientCompatibilityConfirmation = true,
};
_verifiedRecord = gatedRecord;
return new LauncherInstallResult(gatedRecord);
}
catch
{
if (compatibilityMarkerPublished)
{
_contentStateStore.ClearClientCompatibilityPending();
}
throw;
}
finally
{
_installGate.Release();
}
}
private async Task<LauncherInstallResult> InstallOverlayCoreAsync(
string datDirectory,
int threads,
ContentMigrationPlan migration,
IProgress<LauncherInstallProgress>? progress,
CancellationToken cancellationToken)
{
if (migration.TargetRecipeVersion
!= LauncherInstallRecordStore.CurrentBakeToolVersion)
{
throw new LauncherInstallException(
$"Overlay target recipe {migration.TargetRecipeVersion} does not "
+ $"match launcher recipe "
+ $"{LauncherInstallRecordStore.CurrentBakeToolVersion}.");
}
if (migration.EffectiveDatIds.Count == 0
&& migration.EffectiveLandblocks.Count == 0)
{
throw new LauncherInstallException(
"An overlay migration must name at least one DAT id or landblock.");
}
DatDirectoryValidation validation = _datDirectories.Validate(datDirectory);
if (!validation.IsValid)
{
throw new LauncherInstallException(
validation.Message + FormatMissing(validation.MissingFileNames));
}
if (!File.Exists(_bakeExecutablePath))
{
throw new LauncherInstallException(
$"The co-deployed bake tool is missing at '{_bakeExecutablePath}'.");
}
InstallRecordVerification baseVerification =
await RecoverExistingUnderPublicationGuardAsync(cancellationToken)
.ConfigureAwait(false);
LauncherInstallRecord baseRecord = baseVerification.Record
?? throw new LauncherInstallException(
"A verified base pak is required before building an overlay.");
if (baseRecord.BakeToolVersion != migration.FromRecipeVersion)
{
throw new LauncherInstallException(
$"The overlay plan starts at recipe {migration.FromRecipeVersion}, "
+ $"but the installed base is recipe {baseRecord.BakeToolVersion}.");
}
(LauncherContentState? priorState, string? priorStateError) =
await _contentStateStore.LoadAsync(
baseRecord,
forceFullVerification: false,
cancellationToken)
.ConfigureAwait(false);
if (priorStateError is not null)
{
throw new LauncherInstallException(priorStateError);
}
string candidatePath = _contentStateStore.OverlayCandidatePath;
LauncherInstallRecordStore.TryDelete(candidatePath);
string? publicationNonce = null;
string? publishedOverlayPath = null;
bool statePublished = false;
var parser = new BakeProgressJsonlParser();
var protocol = new BakeProgressProtocol();
void Observe(BakeProgressEvent progressEvent)
{
bool accepted = protocol.Observe(progressEvent);
switch (progressEvent)
{
case BakeWorkProgressEvent value when accepted:
LauncherInstallPhase phase = value.Phase == "collision"
? LauncherInstallPhase.BakingCollision
: LauncherInstallPhase.BakingMeshes;
Report(
progress,
phase,
$"Building world-data overlay: {value.Completed:N0}/"
+ $"{value.Total:N0}; failures: {value.Failures:N0}",
value.Completed,
value.Total,
value.Failures,
value.EtaSeconds);
break;
case BakeErrorEvent value when accepted:
Report(progress, LauncherInstallPhase.Failed, value.Message);
break;
case MalformedBakeProgressEvent value:
Report(progress, LauncherInstallPhase.Failed, value.Reason);
break;
}
}
try
{
Report(
progress,
LauncherInstallPhase.PreparingOutput,
"Preparing a small filtered overlay beside active content...");
publicationNonce = BakePublicationGuardPaths.CreateNonce();
await using (
BakePublicationGuardContract.PublicationLease publication =
await BakePublicationGuardContract.AcquireAsync(
candidatePath,
cancellationToken)
.ConfigureAwait(false))
{
BakePublicationGuardContract.Authorize(
candidatePath,
publicationNonce,
publication);
}
var request = new BakeProcessRequest(
_bakeExecutablePath,
validation.Directory,
candidatePath,
threads,
publicationNonce,
migration.EffectiveDatIds,
migration.EffectiveLandblocks);
BakeProcessResult processResult = await _processRunner.RunAsync(
request,
chunk =>
{
foreach (BakeProgressEvent value in parser.Append(chunk))
{
Observe(value);
}
},
cancellationToken)
.ConfigureAwait(false);
foreach (BakeProgressEvent value in parser.Complete())
{
Observe(value);
}
protocol.CompleteInput();
cancellationToken.ThrowIfCancellationRequested();
if (protocol.Violation is not null)
{
throw new LauncherInstallException(protocol.Violation);
}
if (processResult.ExitCode != 0)
{
throw new LauncherInstallException(
BuildChildFailure(
processResult.ExitCode,
protocol.Error?.Message,
processResult.StandardError));
}
if (protocol.Error is not null)
{
throw new LauncherInstallException(protocol.Error.Message);
}
BakeStartedEvent started = protocol.Started
?? throw new LauncherInstallException(
"The bake protocol did not report a started event.");
BakeCompletedEvent completed = protocol.Completed
?? throw new LauncherInstallException(
"The bake protocol did not report a completed event.");
if (started.BakeToolVersion != migration.TargetRecipeVersion
|| completed.BakeToolVersion != migration.TargetRecipeVersion
|| completed.Failures != 0)
{
throw new LauncherInstallException(
"The filtered bake did not complete with the requested recipe.");
}
if (!File.Exists(candidatePath))
{
throw new LauncherInstallException(
"The filtered bake did not publish an overlay candidate.");
}
long size = new FileInfo(candidatePath).Length;
if (size <= 0 || size != completed.OutputBytes)
{
throw new LauncherInstallException(
"The overlay candidate size does not match bake completion.");
}
Report(
progress,
LauncherInstallPhase.VerifyingPackage,
"Verifying the small world-data overlay...");
string sha256 = await _computeSha256(candidatePath, cancellationToken)
.ConfigureAwait(false);
string fileName = $"acdream-update-{migration.TargetRecipeVersion}-"
+ $"{sha256[..12].ToLowerInvariant()}.pak";
var overlay = new LauncherContentOverlay(
fileName,
sha256,
size,
migration.TargetRecipeVersion);
string? candidateError = _contentStateStore.ValidateCandidate(
baseRecord,
candidatePath,
overlay);
if (candidateError is not null)
{
throw new LauncherInstallException(candidateError);
}
publishedOverlayPath = _contentStateStore.GetOverlayPath(overlay);
File.Move(candidatePath, publishedOverlayPath, overwrite: true);
await FinalizeSuccessfulPublicationAsync(
candidatePath,
backupPath: candidatePath + ".unused",
publicationNonce)
.ConfigureAwait(false);
var state = new LauncherContentState(
LauncherContentState.CurrentSchemaVersion,
baseRecord.PreparedAssetSha256,
migration.TargetRecipeVersion,
overlay);
Report(
progress,
LauncherInstallPhase.SavingRecord,
"Activating the verified world-data overlay...");
await _contentStateStore.SaveAtomicallyAsync(
baseRecord,
state,
cancellationToken)
.ConfigureAwait(false);
statePublished = true;
if (priorState?.Overlay is not null)
{
string priorPath = _contentStateStore.GetOverlayPath(
priorState.Overlay);
if (!PathsEqual(priorPath, publishedOverlayPath))
{
LauncherInstallRecordStore.TryDelete(priorPath);
}
}
var resolvedRecord = baseRecord with
{
PreparedAssetOverlayPath = publishedOverlayPath,
EffectiveBakeToolVersion = migration.TargetRecipeVersion,
};
_verifiedRecord = resolvedRecord;
Report(
progress,
LauncherInstallPhase.Completed,
"World data overlay installed and verified.",
1,
1);
return new LauncherInstallResult(resolvedRecord);
}
catch (OperationCanceledException)
{
await FinalizeOverlayFailureAsync(
candidatePath,
publishedOverlayPath,
statePublished,
publicationNonce)
.ConfigureAwait(false);
Report(
progress,
LauncherInstallPhase.Cancelled,
"World data update cancelled; active content was preserved.");
throw;
}
catch (Exception ex)
{
await FinalizeOverlayFailureAsync(
candidatePath,
publishedOverlayPath,
statePublished,
publicationNonce)
.ConfigureAwait(false);
Report(
progress,
LauncherInstallPhase.Failed,
$"World data update failed: {ex.Message}");
if (ex is LauncherInstallException)
{
throw;
}
throw new LauncherInstallException("World data update failed.", ex);
}
}
private async Task<InstallRecordVerification> ResolveContentStateAsync(
InstallRecordVerification baseVerification,
bool forceFullVerification,
CancellationToken cancellationToken)
{
LauncherInstallRecord? record = baseVerification.Record;
if (record is null)
{
return baseVerification;
}
if (baseVerification.IsVerified
&& record.BakeToolVersion
== LauncherInstallRecordStore.CurrentBakeToolVersion)
{
// A complete current-recipe base is sufficient by itself. A
// sidecar from a newer launcher may remain across client rollback;
// this client safely ignores it instead of rejecting the base.
return baseVerification;
}
(LauncherContentState? state, string? error) =
await _contentStateStore.LoadAsync(
record,
forceFullVerification,
cancellationToken)
.ConfigureAwait(false);
if (error is not null)
{
if (!forceFullVerification
&& baseVerification.RequiresContentUpdate)
{
return baseVerification with
{
Status = baseVerification.Status
+ " The previous overlay was ignored because it is invalid.",
};
}
return new InstallRecordVerification(
InstallRecordVerificationState.Invalid,
null,
error);
}
if (state?.Overlay is not null)
{
if (state.EffectiveRecipeVersion
> LauncherInstallRecordStore.CurrentBakeToolVersion)
{
return new InstallRecordVerification(
InstallRecordVerificationState.Invalid,
null,
$"Prepared content recipe {state.EffectiveRecipeVersion} "
+ $"does not match required recipe "
+ $"{LauncherInstallRecordStore.CurrentBakeToolVersion}.");
}
if (state.EffectiveRecipeVersion
< LauncherInstallRecordStore.CurrentBakeToolVersion)
{
ContentMigrationPlan migration;
try
{
migration = ContentMigrationCatalog.Resolve(
record.BakeToolVersion,
LauncherInstallRecordStore.CurrentBakeToolVersion);
}
catch (InvalidOperationException ex)
{
return new InstallRecordVerification(
InstallRecordVerificationState.Invalid,
null,
ex.Message);
}
return new InstallRecordVerification(
InstallRecordVerificationState.ContentUpdateRequired,
record,
$"World data update required: {migration.Reason}.",
migration);
}
return new InstallRecordVerification(
InstallRecordVerificationState.Verified,
record with
{
PreparedAssetOverlayPath =
_contentStateStore.GetOverlayPath(state.Overlay),
EffectiveBakeToolVersion = state.EffectiveRecipeVersion,
},
"Base and overlay client content verified.");
}
return baseVerification;
}
private static async Task FinalizeSuccessfulPublicationAsync(
string outputPath,
string backupPath,
@ -495,22 +1085,58 @@ public sealed class LauncherInstaller : ILauncherInstaller
}
private static async Task FinalizeFailedPublicationAsync(
string publicationPath,
string outputPath,
string backupPath,
bool previousPreserved,
bool canonicalReplaced,
string? publicationNonce)
{
await using BakePublicationGuardContract.PublicationLease publication =
await BakePublicationGuardContract.AcquireAsync(
outputPath,
publicationPath,
CancellationToken.None)
.ConfigureAwait(false);
BakePublicationGuardContract.Invalidate(
outputPath,
publicationPath,
publication,
publicationNonce);
RestorePreviousPackage(outputPath, backupPath, previousPreserved);
BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath);
if (canonicalReplaced)
{
LauncherInstallRecordStore.TryDelete(outputPath);
}
if (previousPreserved && File.Exists(backupPath))
{
File.Move(backupPath, outputPath, overwrite: true);
}
LauncherInstallRecordStore.TryDelete(publicationPath);
LauncherInstallRecordStore.TryDelete(backupPath);
BakeOutputStagingContract.DeleteOwnedStagingFiles(publicationPath);
}
private static async Task FinalizeOverlayFailureAsync(
string candidatePath,
string? publishedOverlayPath,
bool statePublished,
string? publicationNonce)
{
await using BakePublicationGuardContract.PublicationLease publication =
await BakePublicationGuardContract.AcquireAsync(
candidatePath,
CancellationToken.None)
.ConfigureAwait(false);
BakePublicationGuardContract.Invalidate(
candidatePath,
publication,
publicationNonce);
LauncherInstallRecordStore.TryDelete(candidatePath);
BakeOutputStagingContract.DeleteOwnedStagingFiles(candidatePath);
if (!statePublished && publishedOverlayPath is not null)
{
LauncherInstallRecordStore.TryDelete(publishedOverlayPath);
}
}
private bool PreservePreviousPackage(string outputPath, string backupPath)
@ -527,20 +1153,8 @@ public sealed class LauncherInstaller : ILauncherInstaller
return true;
}
private static void RestorePreviousPackage(
string outputPath,
string backupPath,
bool previousPreserved)
{
if (previousPreserved && File.Exists(backupPath))
{
File.Move(backupPath, outputPath, overwrite: true);
return;
}
LauncherInstallRecordStore.TryDelete(outputPath);
LauncherInstallRecordStore.TryDelete(backupPath);
}
internal static string GetFullRebuildCandidatePath(string outputPath) =>
outputPath + ".candidate";
private static string BuildChildFailure(
int exitCode,

View file

@ -1,3 +1,5 @@
using System.Text.Json.Serialization;
namespace AcDream.Launcher.Core.Launching;
/// <summary>
@ -23,4 +25,29 @@ public sealed record LauncherInstallRecord(
&& PreparedAssetSha256.Length == 64
&& PreparedAssetSize > 0
&& BakeToolVersion > 0;
/// <summary>
/// Resolved overlay metadata is runtime-only. It is explicitly excluded
/// from install.json so old strict-schema launchers continue accepting the
/// base record after a newer launcher publishes content.current.json.
/// </summary>
[JsonIgnore]
public string? PreparedAssetOverlayPath { get; init; }
[JsonIgnore]
public uint EffectiveBakeToolVersion { get; init; }
/// <summary>
/// Transient launcher gate restored from a tiny marker beside the pak.
/// It is not part of strict schema-1 install.json and is never copied into
/// a client session configuration.
/// </summary>
[JsonIgnore]
public bool RequiresClientCompatibilityConfirmation { get; init; }
[JsonIgnore]
public uint ResolvedBakeToolVersion =>
EffectiveBakeToolVersion == 0
? BakeToolVersion
: EffectiveBakeToolVersion;
}

View file

@ -114,6 +114,7 @@ public static class SessionConfigComposer
ArgumentNullException.ThrowIfNull(install);
ArgumentNullException.ThrowIfNull(paths);
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
EnsureClientCompatibilityConfirmed(install);
(string configFilePath, string statusFilePath, string stderrLogPath) =
BuildSessionPaths(paths, sessionId);
@ -154,6 +155,15 @@ public static class SessionConfigComposer
{
DatDirectory = install.DatDirectory,
PreparedAssetPath = install.PreparedAssetPath,
PreparedAssetOverlayPath = install.PreparedAssetOverlayPath,
PreparedAssetBaseRecipeVersion =
install.PreparedAssetOverlayPath is null
? null
: install.BakeToolVersion,
PreparedAssetEffectiveRecipeVersion =
install.PreparedAssetOverlayPath is null
? null
: install.ResolvedBakeToolVersion,
},
},
Sessions = [descriptor],
@ -188,6 +198,7 @@ public static class SessionConfigComposer
ArgumentNullException.ThrowIfNull(install);
ArgumentNullException.ThrowIfNull(paths);
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
EnsureClientCompatibilityConfirmed(install);
(string configFilePath, string statusFilePath, string stderrLogPath) =
BuildSessionPaths(paths, sessionId);
@ -219,6 +230,15 @@ public static class SessionConfigComposer
{
DatDirectory = install.DatDirectory,
PreparedAssetPath = install.PreparedAssetPath,
PreparedAssetOverlayPath = install.PreparedAssetOverlayPath,
PreparedAssetBaseRecipeVersion =
install.PreparedAssetOverlayPath is null
? null
: install.BakeToolVersion,
PreparedAssetEffectiveRecipeVersion =
install.PreparedAssetOverlayPath is null
? null
: install.ResolvedBakeToolVersion,
},
},
Sessions = [descriptor],
@ -304,6 +324,17 @@ public static class SessionConfigComposer
return [.. configured];
}
private static void EnsureClientCompatibilityConfirmed(
LauncherInstallRecord install)
{
if (install.RequiresClientCompatibilityConfirmation)
{
throw new InvalidOperationException(
"Prepared content cannot be launched until the matching client "
+ "has been confirmed or installed.");
}
}
private static ComposedSessionConfig Write(ComposedSessionConfig composed)
{
string? directory = Path.GetDirectoryName(composed.ConfigFilePath);

View file

@ -70,6 +70,15 @@ public sealed class SessionContentDescriptor
public string DatDirectory { get; init; } = string.Empty;
public string PreparedAssetPath { get; init; } = string.Empty;
/// <summary>Present only for a layered prepared-content session.</summary>
public string? PreparedAssetOverlayPath { get; init; }
/// <summary>Present with an overlay so the client can validate both pak
/// headers without assuming the base uses its current recipe.</summary>
public uint? PreparedAssetBaseRecipeVersion { get; init; }
public uint? PreparedAssetEffectiveRecipeVersion { get; init; }
}
public sealed class SessionDescriptor

View file

@ -27,6 +27,17 @@ public interface ILauncherOrchestrator : IDisposable
void SetInstallRecord(LauncherInstallRecord? installRecord);
/// <summary>
/// Publishes the exact result of asynchronous launcher content discovery.
/// The default keeps test/injected implementations source-compatible;
/// production additionally retains <paramref name="installationStatus"/>
/// so a failed check is not flattened into a misleading first-run message.
/// </summary>
void SetInstallationState(
LauncherInstallRecord? installRecord,
string installationStatus) =>
SetInstallRecord(installRecord);
void AddServer(string name, string host, int port);
void EditServer(string name, string newName, string newHost, int newPort);

View file

@ -242,13 +242,23 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
public void SetInstallRecord(LauncherInstallRecord? installRecord)
{
SetInstallationState(
installRecord,
installRecord is null
? FirstRunRequired
: "Client content SHA-256, size, and bake-tool version verified.");
}
public void SetInstallationState(
LauncherInstallRecord? installRecord,
string installationStatus)
{
ArgumentException.ThrowIfNullOrWhiteSpace(installationStatus);
lock (_gate)
{
ThrowIfDisposed();
_installRecord = installRecord;
_installationStatus = installRecord is null
? FirstRunRequired
: "Client content SHA-256, size, and bake-tool version verified.";
_installationStatus = installationStatus;
}
RaiseStateChanged();

View file

@ -49,23 +49,6 @@ public sealed partial class App : Application
Path.Combine(
AppContext.BaseDirectory,
"acdream-bake" + executableSuffix));
InstallRecordVerification verification;
try
{
// Hashing the package before constructing the orchestrator is
// intentional: no launch action is enabled until the persisted
// size/SHA/tool-version record has been verified.
verification = installer.LoadExistingAsync()
.GetAwaiter()
.GetResult();
}
catch (Exception ex)
{
verification = new InstallRecordVerification(
InstallRecordVerificationState.Invalid,
null,
$"Client content verification failed: {ex.Message}");
}
LauncherUpdateComposition updates = LauncherUpdateComposition.Create(
paths,
@ -81,8 +64,8 @@ public sealed partial class App : Application
profiles,
paths,
updates.Executables,
verification.Record,
installationStatus: verification.Status,
installRecord: null,
installationStatus: "Checking installed game content…",
updateSessionBarrier: updates.Versions.Barrier);
// LU2: a launcher update installs and restarts by itself. The
// helper waits on THIS process id and cannot replace files the
@ -108,18 +91,32 @@ public sealed partial class App : Application
updates.Updater,
applyLauncherUpdate,
() => desktop.Shutdown());
_viewModel.Initialize();
desktop.MainWindow = new MainWindow
var mainWindow = new MainWindow
{
DataContext = _viewModel,
};
desktop.MainWindow = mainWindow;
_viewModel.Initialize();
mainWindow.Opened += OnMainWindowOpened;
desktop.Exit += OnDesktopExit;
}
base.OnFrameworkInitializationCompleted();
}
private void OnMainWindowOpened(object? sender, EventArgs e)
{
if (sender is MainWindow window)
{
window.Opened -= OnMainWindowOpened;
}
if (_viewModel is not null)
{
_ = _viewModel.StartBackgroundInitializationAsync();
}
}
private void OnDesktopExit(object? sender, ControlledApplicationLifetimeExitEventArgs e)
{
_viewModel?.Dispose();

View file

@ -71,9 +71,15 @@ internal sealed class LauncherUpdateComposition : IDisposable
ReleaseManifestClient? manifestClient = null;
try
{
_ = initialize is null
? versions.LoadAndRecoverAsync(rid).GetAwaiter().GetResult()
: initialize(versions, rid);
// Production initialization is deliberately deferred until after
// MainWindow.Opened. Client-version recovery verifies every file
// in the active version and is therefore not composition-root
// work. The injectable callback remains only for focused failure
// composition tests.
if (initialize is not null)
{
_ = initialize(versions, rid);
}
artifactClient = new HttpClient(
new HttpClientHandler
{

View file

@ -56,14 +56,15 @@
<Border Classes="card"
Padding="12"
Background="#4B3820"
IsVisible="{Binding IsFirstRunRequired}">
IsVisible="{Binding ShowInstallationBanner}">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Spacing="3">
<TextBlock Text="Client setup required" FontWeight="SemiBold" />
<TextBlock Text="{Binding InstallationBannerTitle}" FontWeight="SemiBold" />
<TextBlock Text="{Binding InstallationStatus}" TextWrapping="Wrap" />
</StackPanel>
<Button Grid.Column="1"
Content="Open setup"
IsVisible="{Binding !IsInstallationChecking}"
Command="{Binding FirstRunWizardShell.OpenCommand}" />
</Grid>
</Border>
@ -472,7 +473,7 @@
IsCancel="True"
AutomationProperties.Name="Close first-run setup"
Command="{Binding FirstRunWizardShell.CloseCommand}" />
<Button Content="Build and install"
<Button Content="{Binding FirstRunWizardShell.StartActionText}"
Classes="primary"
IsDefault="True"
AutomationProperties.Name="Build and install client content"

View file

@ -30,6 +30,9 @@ public sealed class FirstRunInstallerViewModel : ObservableObject, IDisposable
private bool _isOpen;
private bool _isRunning;
private bool _isDatDirectoryValid;
private LauncherInstallRecord? _contentUpdateBase;
private ContentMigrationPlan? _contentMigration;
private string? _completionRequirement;
private bool _disposed;
public FirstRunInstallerViewModel(
@ -57,12 +60,24 @@ public sealed class FirstRunInstallerViewModel : ObservableObject, IDisposable
() => IsRunning && _cancellation is not null);
}
public string Title => "First-run setup";
public bool IsContentUpdate => _contentMigration is not null;
public string Body =>
"Select the retail Asheron's Call DAT folder. acdream will validate "
+ "the four required files, build DataDirectory/pak/acdream.pak, and "
+ "verify its SHA-256 before enabling launch.";
public string Title => IsContentUpdate
? "World data update required"
: "First-run setup";
public string Body => IsContentUpdate
? BuildContentUpdateBody()
: "Select the retail Asheron's Call DAT folder. acdream will validate "
+ "the four required files, build DataDirectory/pak/acdream.pak, and "
+ "verify its SHA-256 before enabling launch. No work begins until "
+ "you choose Build and install.";
public string StartActionText => IsContentUpdate
? _contentMigration?.Kind == ContentWorkKind.Overlay
? "Build small update"
: "Rebuild world data"
: "Build and install";
public string DatDirectory
{
@ -233,10 +248,51 @@ public sealed class FirstRunInstallerViewModel : ObservableObject, IDisposable
/// <summary>The form and the completion panel are mutually exclusive.</summary>
public bool ShowSetupForm => !IsCompleted;
public string CompletedTitle => "Setup complete";
public string CompletedTitle => IsContentUpdate
? "World data updated"
: "Setup complete";
public string CompletedBody =>
"acdream built and verified your game content. You can play now.";
public string CompletedBody => IsContentUpdate
? _completionRequirement
?? "acdream built and verified the required world data. You can play now."
: "acdream built and verified your game content. You can play now.";
/// <summary>
/// Keeps the completion panel honest when prepared content is ready but
/// cannot be paired with the active client until the update step finishes.
/// </summary>
public void SetCompletionRequirement(string? requirement)
{
_completionRequirement = string.IsNullOrWhiteSpace(requirement)
? null
: requirement;
OnPropertyChanged(nameof(CompletedBody));
}
/// <summary>
/// Switches the existing setup surface into an explicit content-update
/// confirmation. Merely opening this surface never starts a hash or bake.
/// </summary>
public void PrepareContentUpdate(
LauncherInstallRecord baseRecord,
ContentMigrationPlan migration)
{
ArgumentNullException.ThrowIfNull(baseRecord);
ArgumentNullException.ThrowIfNull(migration);
_contentUpdateBase = baseRecord;
_contentMigration = migration;
SetCompletionRequirement(null);
_datDirectory = baseRecord.DatDirectory;
Status = "Review the required work. Nothing has started.";
OnPropertyChanged(nameof(DatDirectory));
OnPropertyChanged(nameof(IsContentUpdate));
OnPropertyChanged(nameof(Title));
OnPropertyChanged(nameof(Body));
OnPropertyChanged(nameof(StartActionText));
OnPropertyChanged(nameof(CompletedTitle));
OnPropertyChanged(nameof(CompletedBody));
Open();
}
public void NotifyCommandStates()
{
@ -277,6 +333,7 @@ public sealed class FirstRunInstallerViewModel : ObservableObject, IDisposable
{
IsCompleted = false;
IsOpen = false;
ClearContentUpdateMode();
}
private void Open()
@ -343,22 +400,33 @@ public sealed class FirstRunInstallerViewModel : ObservableObject, IDisposable
Error = null;
ProgressPercent = 0;
Phase = LauncherInstallPhase.ValidatingDatFiles;
Status = "Starting installation...";
Status = IsContentUpdate
? "Starting the approved world-data update..."
: "Starting installation...";
var progress = new CallbackProgress<LauncherInstallProgress>(value =>
_dispatcher.Post(() => ApplyProgress(value)));
try
{
LauncherInstallResult result = await _installer.InstallAsync(
DatDirectory,
threads,
progress,
cancellation.Token)
LauncherInstallResult result = await (IsContentUpdate
? _installer.ApplyContentUpdateAsync(
DatDirectory,
threads,
_contentMigration!,
progress,
cancellation.Token)
: _installer.InstallAsync(
DatDirectory,
threads,
progress,
cancellation.Token))
.ConfigureAwait(true);
_onInstalled(result.Record);
Phase = LauncherInstallPhase.Completed;
ProgressPercent = 100;
Status = "Client content installed and verified. Launch is enabled.";
Status = _completionRequirement is null
? "Client content installed and verified. Launch is enabled."
: "World data is ready. The matching game update is still required.";
// LU4: raised only here, AFTER the record is published, so the
// launcher behind the dialog is already in its launch-enabled
// state when the user presses OK. The cancelled and failed
@ -389,6 +457,44 @@ public sealed class FirstRunInstallerViewModel : ObservableObject, IDisposable
}
}
private string BuildContentUpdateBody()
{
ContentMigrationPlan migration = _contentMigration!;
string work = migration.Kind == ContentWorkKind.Overlay
? "a small filtered overlay"
: "a complete replacement pak";
string estimate = migration.Kind == ContentWorkKind.Overlay
? $"Affected filters: {migration.EffectiveDatIds.Count:N0} DAT id(s), "
+ $"{migration.EffectiveLandblocks.Count:N0} landblock(s)."
: _contentUpdateBase is { PreparedAssetSize: > 0 } record
? $"Free-space guidance: allow about "
+ $"{Math.Ceiling(record.PreparedAssetSize * 1.1 / (1024d * 1024d * 1024d)):N0} GiB."
: "Free-space guidance: allow room for one complete replacement pak.";
return $"This client needs recipe {migration.TargetRecipeVersion}: "
+ $"{migration.Reason}. acdream will build {work} from your installed "
+ "Asheron's Call DAT files. The existing package stays in place "
+ $"until the new one has finished and verified. {estimate} "
+ "No work begins until you confirm below.";
}
private void ClearContentUpdateMode()
{
if (_contentMigration is null)
{
return;
}
_contentMigration = null;
_contentUpdateBase = null;
SetCompletionRequirement(null);
OnPropertyChanged(nameof(IsContentUpdate));
OnPropertyChanged(nameof(Title));
OnPropertyChanged(nameof(Body));
OnPropertyChanged(nameof(StartActionText));
OnPropertyChanged(nameof(CompletedTitle));
OnPropertyChanged(nameof(CompletedBody));
}
private void ApplyProgress(LauncherInstallProgress progress)
{
if (_disposed)

View file

@ -27,6 +27,7 @@ public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable
private readonly ILauncherUpdater _updater;
private readonly IUiDispatcher _dispatcher;
private readonly Action _onClientChanged;
private readonly Func<bool> _canOpen;
private readonly Func<bool> _canMutate;
private readonly Func<CancellationToken, Task<bool>>? _applyLauncherUpdateAsync;
private readonly Action? _requestShutdown;
@ -40,6 +41,13 @@ public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable
private double _progressPercent;
private bool _isProgressIndeterminate;
/// <summary>
/// Raised after the one startup check has either produced an authoritative
/// result or failed. Content publication uses this edge to avoid pairing a
/// newly prepared pak with an unconfirmed active client.
/// </summary>
public event EventHandler? StartupCheckCompleted;
/// <param name="applyLauncherUpdateAsync">Applies an already-staged
/// launcher update against the running process, returning true when the
/// replacement helper started and this process must now exit. Null (tests,
@ -60,9 +68,7 @@ public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_onClientChanged = onClientChanged
?? throw new ArgumentNullException(nameof(onClientChanged));
// Retained so the composition root's call shape is unchanged; there is
// no user-openable update panel any more, so nothing consults it.
_ = canOpen;
_canOpen = canOpen ?? (() => true);
_canMutate = canMutate ?? (() => true);
_applyLauncherUpdateAsync = applyLauncherUpdateAsync;
_requestShutdown = requestShutdown;
@ -174,6 +180,10 @@ public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable
public bool IsLauncherUpdateAvailable => _check?.IsLauncherUpdateAvailable == true;
public bool IsStartupCheckComplete { get; private set; }
public bool StartupCheckSucceeded { get; private set; }
/// <summary>The single affirmative action. See <see cref="UpdateAsync"/>.</summary>
public AsyncRelayCommand UpdateCommand { get; }
@ -195,16 +205,24 @@ public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable
using var cancellation = new CancellationTokenSource();
_cancellation = cancellation;
IsStartupCheckComplete = false;
StartupCheckSucceeded = false;
IsBusy = true;
try
{
// The composition root no longer verifies the active client before
// the launcher window exists. Recover/verify it here, immediately
// before the one startup feed check, while the UI is responsive.
_ = await _updater.InitializeAsync(cancellation.Token)
.ConfigureAwait(true);
_check = await _updater.CheckAsync(cancellation.Token).ConfigureAwait(true);
StartupCheckSucceeded = true;
OnPropertyChanged(nameof(Body));
OnPropertyChanged(nameof(IsClientUpdateAvailable));
OnPropertyChanged(nameof(IsLauncherUpdateAvailable));
if (HasSomethingToUpdate)
{
IsOpen = true;
TryOpenPendingUpdate();
}
}
catch
@ -221,6 +239,10 @@ public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable
}
IsBusy = false;
IsStartupCheckComplete = true;
OnPropertyChanged(nameof(IsStartupCheckComplete));
OnPropertyChanged(nameof(StartupCheckSucceeded));
StartupCheckCompleted?.Invoke(this, EventArgs.Empty);
}
}
@ -232,6 +254,16 @@ public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable
}
}
/// <summary>Opens a previously discovered update once another startup
/// question (notably required world-data work) has finished.</summary>
public void TryOpenPendingUpdate()
{
if (!_disposed && HasSomethingToUpdate && _canOpen())
{
IsOpen = true;
}
}
public void NotifyCommandStates()
{
UpdateCommand.NotifyCanExecuteChanged();

View file

@ -27,7 +27,15 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
private LauncherTreeNodeViewModel? _selectedNode;
private CancellationTokenSource? _operationCancellation;
private bool _isBusy;
private bool _isInstallationChecking = true;
private bool _isClientCompatibilityCheckBlocking;
private bool _isContentUpdateRequired;
private bool _disposed;
private Task? _startupInitialization;
private bool _openUpdateAfterContentCompletion;
private bool _isClientCompatibilityPending;
private LauncherInstallRecord? _pendingInstalledContent;
private readonly CancellationTokenSource _startupCancellation = new();
private string? _lastError;
private string _operationStatus = "Ready";
private LaunchMode _characterLaunchMode;
@ -66,6 +74,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
EditorDialog.PropertyChanged += OnModalPropertyChanged;
FirstRunWizardShell.PropertyChanged += OnModalPropertyChanged;
UpdatePrompt.PropertyChanged += OnModalPropertyChanged;
UpdatePrompt.StartupCheckCompleted += OnStartupUpdateCheckCompleted;
AddServerCommand = new RelayCommand(OpenAddServerDialog, () => CanInteract);
AddAccountCommand = new RelayCommand(OpenAddAccountDialog, CanAddAccount);
@ -195,7 +204,23 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
set => SetProperty(ref _characterLoginCommandsText, value);
}
public bool IsFirstRunRequired => _snapshot is { IsInstallationReady: false };
public bool IsInstallationChecking => _isInstallationChecking;
public bool IsFirstRunRequired =>
!IsInstallationChecking
&& (_isContentUpdateRequired
|| _snapshot is { IsInstallationReady: false });
public bool ShowInstallationBanner =>
IsInstallationChecking || IsFirstRunRequired;
public string InstallationBannerTitle => IsInstallationChecking
? "Checking installation"
: _isClientCompatibilityPending
? "Game update required"
: _isContentUpdateRequired
? "World data update required"
: "Client setup required";
public string InstallationStatus => _snapshot?.InstallationStatus
?? "Installation state is loading.";
@ -211,6 +236,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
public bool CanLaunchAccountGuiSelect =>
CanInteract
&& !_isClientCompatibilityCheckBlocking
&& IsAccountSelected
&& TryGetSelectedAccount(out string server, out string account)
&& _orchestrator.GetAccountLaunchCapability(
@ -291,7 +317,29 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
}
RefreshFromCore();
_ = UpdatePrompt.StartupCheckAsync();
}
/// <summary>
/// Starts only after the real launcher window has raised Opened. The exact
/// order is intentional: content discovery first, versioned-client
/// recovery second (inside StartupCheckAsync), network feed check last.
/// This prevents pre-window hashing and competing startup modals.
/// </summary>
public Task StartBackgroundInitializationAsync()
{
if (_startupInitialization is not null)
{
return _startupInitialization;
}
_isClientCompatibilityCheckBlocking = true;
OnPropertyChanged(nameof(CanLaunchGui));
OnPropertyChanged(nameof(CanLaunchHeadless));
OnPropertyChanged(nameof(CanLaunchAccountGuiSelect));
NotifyCommandStates();
_startupInitialization = InitializeInstalledContentAndUpdatesAsync(
_startupCancellation.Token);
return _startupInitialization;
}
public void PollStatus()
@ -319,6 +367,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
}
_disposed = true;
_startupCancellation.Cancel();
_operationCancellation?.Cancel();
_operationCancellation?.Dispose();
_operationCancellation = null;
@ -326,8 +375,91 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
EditorDialog.PropertyChanged -= OnModalPropertyChanged;
FirstRunWizardShell.PropertyChanged -= OnModalPropertyChanged;
UpdatePrompt.PropertyChanged -= OnModalPropertyChanged;
UpdatePrompt.StartupCheckCompleted -= OnStartupUpdateCheckCompleted;
FirstRunWizardShell.Dispose();
UpdatePrompt.Dispose();
_startupCancellation.Dispose();
}
private async Task InitializeInstalledContentAndUpdatesAsync(
CancellationToken cancellationToken)
{
OperationStatus = "Checking installed game content…";
try
{
InstallRecordVerification verification = await _installer
.LoadExistingWithProgressAsync(
cancellationToken,
progress: new Progress<string>(status =>
OperationStatus = status))
.ConfigureAwait(true);
if (_disposed)
{
return;
}
_isContentUpdateRequired = verification.RequiresContentUpdate;
if (verification.IsVerified
&& verification.Record is
{ RequiresClientCompatibilityConfirmation: true } pendingRecord)
{
_pendingInstalledContent = pendingRecord;
_isClientCompatibilityPending = true;
_orchestrator.SetInstallationState(null, verification.Status);
}
else
{
_orchestrator.SetInstallationState(
verification.IsVerified || verification.RequiresContentUpdate
? verification.Record
: null,
verification.Status);
}
OperationStatus = verification.Status;
if (verification.RequiresContentUpdate
&& verification.Record is not null
&& verification.RequiredContentWork is not null)
{
FirstRunWizardShell.PrepareContentUpdate(
verification.Record,
verification.RequiredContentWork);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
if (_disposed)
{
return;
}
string status = "Client content verification failed: "
+ SafeDisplayError(ex, secret: null);
_orchestrator.SetInstallationState(null, status);
OperationStatus = status;
LastError = status;
}
if (!_disposed && !cancellationToken.IsCancellationRequested)
{
OperationStatus = "Checking for game updates…";
await UpdatePrompt.StartupCheckAsync().ConfigureAwait(true);
}
if (!_disposed)
{
_isClientCompatibilityCheckBlocking = false;
_isInstallationChecking = false;
OnPropertyChanged(nameof(IsInstallationChecking));
OnPropertyChanged(nameof(IsFirstRunRequired));
OnPropertyChanged(nameof(ShowInstallationBanner));
OnPropertyChanged(nameof(InstallationBannerTitle));
OnPropertyChanged(nameof(InstallationStatus));
RefreshFromCore();
}
}
private void OnOrchestratorStateChanged(object? sender, EventArgs e) =>
@ -356,6 +488,14 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
OnPropertyChanged(nameof(ShowGuiLaunchDisabledReason));
OnPropertyChanged(nameof(ShowHeadlessLaunchDisabledReason));
NotifyCommandStates();
if (ReferenceEquals(sender, FirstRunWizardShell)
&& e.PropertyName == nameof(FirstRunInstallerViewModel.IsOpen)
&& !FirstRunWizardShell.IsOpen
&& _openUpdateAfterContentCompletion)
{
_openUpdateAfterContentCompletion = false;
UpdatePrompt.TryOpenPendingUpdate();
}
}
public void CloseActiveModal()
@ -403,6 +543,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
SetSelectedNode(restored, preserveDraft);
OnPropertyChanged(nameof(IsFirstRunRequired));
OnPropertyChanged(nameof(ShowInstallationBanner));
OnPropertyChanged(nameof(InstallationStatus));
OnPropertyChanged(nameof(ShowLinuxGraphicalNotice));
OnPropertyChanged(nameof(LinuxGraphicalNotice));
@ -832,6 +973,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
private bool CanLaunch(LaunchMode mode) =>
CanInteract
&& !_isClientCompatibilityCheckBlocking
&& IsCharacterSelected
&& TryGetSelectedAccount(out string server, out string account)
&& _orchestrator.GetAccountLaunchCapability(server, account, mode).IsAvailable;
@ -967,11 +1109,21 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
try
{
InstallRecordVerification verification = await _installer
.LoadExistingAsync(cancellation.Token, forceFullVerification: true)
.LoadExistingWithProgressAsync(
cancellation.Token,
forceFullVerification: true,
progress: new Progress<string>(status =>
OperationStatus = status))
.ConfigureAwait(true);
_orchestrator.SetInstallRecord(verification.Record);
_isContentUpdateRequired = verification.RequiresContentUpdate;
_orchestrator.SetInstallationState(
verification.IsVerified || verification.RequiresContentUpdate
? verification.Record
: null,
verification.Status);
OperationStatus = verification.Status;
if (!verification.IsVerified)
if (!verification.IsVerified
&& !verification.RequiresContentUpdate)
{
LastError = verification.Status;
}
@ -995,19 +1147,93 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
private void OnInstallCompleted(LauncherInstallRecord record)
{
_orchestrator.SetInstallRecord(record);
OperationStatus = "Client content installed and verified.";
bool attemptImmediateCompatibilityConfirmation = false;
_isContentUpdateRequired = false;
_openUpdateAfterContentCompletion = true;
if (FirstRunWizardShell.IsContentUpdate
&& (record.RequiresClientCompatibilityConfirmation
|| !UpdatePrompt.IsStartupCheckComplete
|| !UpdatePrompt.StartupCheckSucceeded
|| UpdatePrompt.IsClientUpdateAvailable))
{
_pendingInstalledContent = record;
_isClientCompatibilityPending = true;
const string requirement = "acdream built and verified the world data. "
+ "Install the matching game update next; Play stays disabled until it finishes.";
FirstRunWizardShell.SetCompletionRequirement(requirement);
_orchestrator.SetInstallationState(
null,
"World data is ready; install the matching game update before playing.");
OperationStatus = "World data is ready; matching game update required.";
attemptImmediateCompatibilityConfirmation = true;
}
else
{
_orchestrator.SetInstallRecord(record);
OperationStatus = "Client content installed and verified.";
}
LastError = null;
RefreshFromCore();
if (attemptImmediateCompatibilityConfirmation)
{
PublishPendingContentIfCompatible(clientWasInstalled: false);
}
}
private void OnClientVersionChanged()
{
PublishPendingContentIfCompatible(clientWasInstalled: true);
OperationStatus = "Versioned client activation changed.";
LastError = null;
RefreshFromCore();
}
private void OnStartupUpdateCheckCompleted(object? sender, EventArgs e) =>
PublishPendingContentIfCompatible(clientWasInstalled: false);
private void PublishPendingContentIfCompatible(bool clientWasInstalled)
{
if (_pendingInstalledContent is null)
{
return;
}
bool compatible = clientWasInstalled
|| (UpdatePrompt.StartupCheckSucceeded
&& !UpdatePrompt.IsClientUpdateAvailable);
if (!compatible)
{
return;
}
LauncherInstallRecord pendingRecord = _pendingInstalledContent;
try
{
_installer.ConfirmClientCompatibility();
}
catch (Exception ex)
{
string status = "The matching client is ready, but the content gate "
+ "could not be cleared: " + SafeDisplayError(ex, secret: null);
_orchestrator.SetInstallationState(null, status);
OperationStatus = status;
LastError = status;
return;
}
LauncherInstallRecord record = pendingRecord with
{
RequiresClientCompatibilityConfirmation = false,
};
_pendingInstalledContent = null;
_isClientCompatibilityPending = false;
FirstRunWizardShell.SetCompletionRequirement(null);
_orchestrator.SetInstallRecord(record);
OperationStatus = "Client and world data are installed and verified.";
OnPropertyChanged(nameof(InstallationBannerTitle));
}
private void NotifyCommandStates()
{
AddServerCommand.NotifyCanExecuteChanged();