feat(launcher): stabilize prepared content updates
This commit is contained in:
parent
f160f3fee1
commit
af9327a17b
42 changed files with 3706 additions and 147 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1418,6 +1418,9 @@ public sealed class GameWindow :
|
|||
new ContentEffectsAudioDependencies(
|
||||
_datDir,
|
||||
_options.PreparedAssetPath,
|
||||
_options.PreparedAssetOverlayPath,
|
||||
_options.PreparedAssetBaseRecipeVersion,
|
||||
_options.PreparedAssetEffectiveRecipeVersion,
|
||||
_options.ResidencyBudgets,
|
||||
_physicsDataCache,
|
||||
_animationDiagnostics.DumpMotionEnabled,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
239
src/AcDream.Content/LayeredPreparedAssetSource.cs
Normal file
239
src/AcDream.Content/LayeredPreparedAssetSource.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue