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

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

View file

@ -332,7 +332,19 @@ src/
Installation/ -> portable four-DAT validation, Windows retail
path discovery, versioned JSONL bake-process
orchestration, and atomic SHA/size/tool-version
install-record verification and recovery; one
install-record verification and recovery;
startup discovery begins only after the desktop
window opens, and exceptional whole-pak hashing
reports its long-read status in that window;
content recipes resolve through one compiled
None/Overlay/FullRebuild/Verify migration ledger;
bounded changes build one cumulative filtered
overlay and publish `pak/content.current.json`,
while full rebuilds bake beside the live base and
swap only after candidate verification; a tiny
`pak/content.client-pending` gate survives a
crash/restart until the active client is
confirmed compatible; one
OS-handle lease serializes recovery/install per
DataDirectory; a second OS-held publication
lock plus durable per-transaction nonce makes
@ -362,7 +374,9 @@ src/
is never persisted, and permits HTTP only for a
loopback fixture; production remains pinned HTTPS
ViewModels/ -> thin MVVM projection over Launcher.Core,
including the first-run DAT/bake wizard and
including the first-run DAT/bake wizard, explicit
world-data work confirmation (kind, reason,
free-space guidance, progress/cancellation), and
nonfatal startup/manual update state, actions,
progress, cancellation, rollback, and errors
-> references Launcher.Core only (Platform transitively); it never owns
@ -372,6 +386,19 @@ src/
-> Linux launcher/probe/headless flows remain portable; graphical-client
actions are explicitly disabled until Modern Runtime Slice L resumes
Prepared-content launch contract
-> `install.json` remains the strict backward-compatible base-pak authority
-> optional `pak/content.current.json` binds one cumulative overlay to the
base SHA; there is never an unbounded overlay chain
-> `pak/content.client-pending` prevents newly migrated content from
becoming launchable before the matching client check/install succeeds,
including across launcher restart
-> launcher session config carries base + optional overlay paths and both
recipe identities only for layered launches
-> App and Headless construct one `LayeredPreparedAssetSource`; overlay
Missing falls through to base, while overlay Corrupt is authoritative
for both render and collision reads
AcDream.Headless/ Linux/Windows no-window production host
Program.cs -> CLI entry only
Configuration/ -> strict versioned process/session config

View file

@ -132,6 +132,20 @@ bake/equivalence/UI-Studio tools, not a production fallback. Portal → HighRes
installed-DAT gates are recorded in
`docs/research/2026-07-24-slice-c-prepared-asset-cutover-report.md`.
**Launcher cumulative-overlay extension (2026-08-25).** Production still has
no live-DAT fallback and consumes the same prepared-payload contracts. For a
bounded recipe migration, App and Headless may receive one complete base pak
plus one cumulative filtered pak through `LayeredPreparedAssetSource`. The
overlay is probed first: Missing falls through to the base, while a present but
corrupt render or collision payload remains authoritative corruption. Both
mapped owners share one composite lifetime and there is never an overlay
chain. The launcher binds the overlay to the base digest in the optional
`pak/content.current.json` sidecar; format/global extraction migrations retain
the explicit full-rebuild path. A tiny `pak/content.client-pending` marker
keeps either result non-launchable until the matching client is confirmed,
including across a crash/restart. Design and gates:
`docs/plans/2026-08-25-launcher-content-stabilization.md`.
**Slice I3 prepared collision extension (2026-07-25).** The package remains
format 1 and retains mesh type values 13; bake-tool 4 appends typed GfxObj,
Setup, CellStruct, and EnvCell-topology collision payloads. Core owns the

View file

@ -0,0 +1,230 @@
# Launcher content stabilization
**Date:** 2026-08-25
**Status:** IMPLEMENTED
**Goal:** make prepared-content updates fast, explicit, and safe without
turning the launcher into a package manager.
## User contract
1. The launcher window appears before network access, full-file hashing,
baking, recovery, or any other potentially long operation.
2. Ordinary startup reads only small metadata: the install record, pak header,
file length/write time, and the verification sidecar when present.
3. No long content operation begins silently. The launcher first names the
reason, work kind, approximate disk requirement, and whether the existing
installed game remains usable.
4. A prepared-content change uses a small locally generated overlay whenever
the affected DAT IDs/landblocks are bounded. A full rebuild is an explicit,
rare fallback for format changes or extraction changes with unbounded
impact.
5. The launcher never starts a mixed client/content pair. Cancellation or a
bake/publication failure preserves the prior pair. Once approved content is
ready, an unavailable or failed matching-client update leaves Play disabled
and retains the verified content for a cheap retry.
## What exists already
- `acdream.pak` has a 64-byte header containing DAT iterations, format version,
and `BakeToolVersion` (the current content-recipe identity).
- `install.json` records the pak SHA-256, size, DAT path, and recipe identity.
- `install.verification.json` avoids the former 24-second startup hash when
size/write-time still match. Explicit **Verify files** remains the full-hash
path.
- `acdream-bake` already accepts `--ids` and `--landblocks`, and a filtered
bake produces an ordinary valid pak with only those typed keys.
- `IPreparedAssetSource` and `IPreparedCollisionSource` are the existing
renderer/physics seams; no consumer needs to know which mapped pak supplied a
key.
- Launcher and client are published together, and the launcher payload already
includes the matching bake executable.
## Deliberately small model
There are only four work kinds:
| Kind | Launcher behavior |
|---|---|
| `None` | No content prompt. |
| `Overlay` | Build one cumulative overlay containing all keys changed since the base recipe. |
| `FullRebuild` | Explain the long rebuild and required free space before starting. |
| `Verify` | User-requested or exceptional recovery hash; always visible and cancellable. |
The release-feed schema remains unchanged for the first implementation. Every
published build already updates the launcher before the client. The updated
launcher carries the matching content requirement and a small compiled
migration catalog. This avoids stranding strict schema-1 launchers on a feed
shape they cannot parse. A future independently versioned content feed can
replace the catalog without changing the runtime content model.
`BakeToolVersion` is retained on disk for compatibility but is treated as a
**content recipe version**, not an executable build number. It changes only
when the produced prepared content changes.
## On-disk content state
The existing `install.json` remains the base-pak authority and is not extended;
older launchers reject unknown fields. New state lives in the optional sidecar
`DataDirectory/pak/content.current.json`:
```json
{
"schemaVersion": 1,
"baseSha256": "<sha256 from install.json>",
"effectiveRecipeVersion": 6,
"overlay": {
"path": "acdream-update-6.pak",
"sha256": "<64 lowercase hex>",
"size": 123,
"recipeVersion": 6
}
}
```
Rules:
- The sidecar is valid only when `baseSha256` binds it to the current base
record and every path is a safe canonical filename beneath the pak directory.
- At most one overlay is active. A later overlay is cumulative and atomically
replaces the prior sidecar; there is no unbounded lookup chain.
- The base and overlay must name the same installed DAT iterations and pak
format. The base may carry an older recipe; the overlay carries the effective
recipe.
- Missing overlay keys fall through to the base. A present-but-corrupt overlay
key is authoritative corruption and never falls through.
- Render and collision reads follow the same ordering and share the same two
memory mappings.
- An absent sidecar means the base pak is the complete active content set.
- `content.client-pending` is a separate, tiny crash-safe activation gate. A
content migration creates it before touching content and removes it only
after client compatibility is confirmed. It deliberately carries no package
graph; existence means “do not publish this content to Play yet.”
## Migration catalog
One compiled catalog entry describes each recipe transition:
```text
target recipe
work kind
player-facing reason
affected DAT IDs and/or landblocks (overlay only)
```
To update a base from recipe 5 directly to recipe 7, the launcher asks the
catalog for the cumulative 5 -> 7 impact and emits one recipe-7 overlay. If any
step is `FullRebuild`, the combined migration is a full rebuild. A missing
catalog step fails closed with an explanatory error; it never guesses.
The recent procedural night-sky change is `None` because it changed client
shader/code only. A future addition of bounded prepared sky keys can be
`Overlay`. A global mesh-extraction correction such as recipe 5's solid-face
change is `FullRebuild`.
## Update transaction and UI
The launcher keeps the existing one-question update surface. When the candidate
client needs newer content, pressing **Update** first opens the content-work
confirmation:
> **World data update required**
> This release adds prepared sky assets. acdream will build a small update
> from your installed Asheron's Call files. The existing game stays installed
> until this finishes.
> Estimated work: overlay / approximately N files / M free space required.
> **Update now** · **Later**
After confirmation:
1. Validate the remembered DAT directory and free-space floor.
2. Build to a transaction-owned candidate path while the active content stays
untouched.
3. Validate pak header/TOC and compute the new artifact's SHA once. Never hash
the unchanged base as part of an overlay update.
4. Atomically publish the content sidecar.
5. Install/activate the compatible client.
The newly prepared content is not published to the launch orchestrator until
the startup check confirms that the active client is compatible or the client
update succeeds. Choosing **Not now**, losing the network, or failing the
client download therefore cannot launch the old executable against the new
pak. The launcher keeps the verified content on disk and resumes at the much
smaller client-update step.
For `FullRebuild`, the same transaction builds a candidate base beside the old
base, verifies it, then atomically swaps the base record/file. It never moves
the playable base out of place before the long build starts.
Progress uses the existing strict Bake JSONL protocol and shows phase,
percentage, failures, and ETA. Cancellation returns to the launcher without
changing active content.
## Startup ordering
`App.OnFrameworkInitializationCompleted` must not synchronously wait on
`LoadExistingAsync` before constructing `MainWindow`. It constructs the shell
with an explicit `Checking` installation state, assigns/shows the window, then
starts content discovery on the UI dispatcher. Feed update checking begins only
after that cheap discovery completes, preventing two startup modals from
racing.
If an exceptional recovery path really needs a full base hash, the shell is
already visible and says exactly what it is doing. Launch stays disabled until
the recovery check finishes, but the application never looks frozen.
## Compatibility and rollback
- A client session receives the resolved base path plus zero or one overlay
path. Old clients continue receiving only the base.
- The client validates the effective recipe before constructing world owners.
- The updater does not activate a client whose content requirement is
unsatisfied.
- Choosing **Later** leaves the old client/base pair active.
- Client rollback is allowed only when the selected client accepts the active
content set; otherwise the launcher explains the required content rollback
or rebuild instead of launching an incompatible pair.
## Verification gates
- Launcher window construction test proves no installer/hash task is awaited
before the main window is assigned.
- Quick-discovery tests cover missing sidecar, matching sidecar, missing cache,
changed length/time, recipe mismatch, and exceptional visible verification.
- Content-state tests cover path containment, base-digest binding, atomic
publication, cancellation, and crash residue.
- Composite-source tests cover overlay hit, base fallback, authoritative
overlay corruption, render/collision parity, stats, and balanced disposal.
- Session-config round trips cover base-only and base+overlay on App and
Headless.
- Update tests prove prepared content cannot become launchable before client
compatibility is confirmed; **Not now** and client-download failure remain
fail-closed, while bake/candidate failure preserves the prior pair.
- Release solution compilation and the affected Launcher, Content, App, and
Headless gates remain green.
## Implementation checkpoint
Implemented 2026-08-25:
- The Avalonia window is assigned and opened before content discovery, client
recovery, feed access, or exceptional hashing begins.
- Ordinary current-install discovery uses metadata/header/cache checks; the
explicit verification command owns visible whole-pak hashing.
- Recipe migrations are compiled and cumulative. Bounded migrations build one
filtered overlay; unbounded/global migrations use the explicit candidate
full-rebuild path. The current recipe 4 -> 5 transition is correctly a full
rebuild because the solid-face extraction change is global.
- Base/overlay reads are unified for render and collision with overlay-first,
Missing-only fallback and authoritative corruption.
- Content activation is bound to client compatibility in memory and through
`content.client-pending`, so **Not now**, failed download, process crash, and
launcher restart cannot expose a mixed pair.
Final Release gates:
- `dotnet build AcDream.slnx -c Release`: 0 warnings, 0 errors.
- Launcher UI/ViewModels, excluding the documented manual desktop lane: 82/82.
- Launcher.Core Windows-compatible suite: 360/360.
- Hermetic Content suite: 130/130.
- Affected App layered/session composition: 35/35.
- Affected Headless configuration: 9/9.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -258,6 +258,9 @@ public sealed class ContentEffectsAudioCompositionTests
Dependencies = new ContentEffectsAudioDependencies(
"test-dat",
"test.pak",
null,
null,
null,
ResidencyBudgetOptions.Default,
new PhysicsDataCache(),
false,
@ -386,6 +389,9 @@ public sealed class ContentEffectsAudioCompositionTests
public IDatReaderWriter OpenDatCollection(string datDirectory) => _dats;
public IPreparedAssetSource OpenPreparedAssetSource(
string path,
string? overlayPath,
uint? baseRecipeVersion,
uint? effectiveRecipeVersion,
IDatReaderWriter dats,
Action<string> diagnostic)
{

View file

@ -137,6 +137,56 @@ public sealed class RuntimeOptionsSessionConfigTests
options.PreparedAssetPath);
}
[Fact]
public void ProcessContentCarriesOptionalOverlayAndBothRecipeIdentities()
{
var config = new SessionConfiguration
{
Version = 1,
Process = new SessionProcessSettings
{
Content = new SessionContentDescriptor
{
DatDirectory = "D:\\dats",
PreparedAssetPath = "D:\\content\\acdream.pak",
PreparedAssetOverlayPath =
"D:\\content\\acdream-update-5.pak",
PreparedAssetBaseRecipeVersion = 4,
PreparedAssetEffectiveRecipeVersion = 5,
},
},
};
var session = new SessionDescriptor
{
Id = "overlay-session",
Endpoint = new SessionEndpointDescriptor
{
Host = "127.0.0.1",
Port = 9000,
},
Account = "account",
Credential = new SessionCredentialDescriptor
{
Provider = SessionCredentialProviderKind.Environment,
Reference = "X",
},
};
RuntimeOptions options = RuntimeOptions.FromSessionConfig(
"D:\\dats",
_ => null,
"session.json",
config,
session,
"password");
Assert.Equal(
"D:\\content\\acdream-update-5.pak",
options.PreparedAssetOverlayPath);
Assert.Equal(4u, options.PreparedAssetBaseRecipeVersion);
Assert.Equal(5u, options.PreparedAssetEffectiveRecipeVersion);
}
[Fact]
public void EnvironmentFlowLeavesEveryNewFieldAtItsNothingConfiguredDefault()
{
@ -149,5 +199,8 @@ public sealed class RuntimeOptionsSessionConfigTests
Assert.Null(options.Plugins);
Assert.Empty(options.LoginCommands);
Assert.Equal(500, options.LoginCommandDelayMs);
Assert.Null(options.PreparedAssetOverlayPath);
Assert.Null(options.PreparedAssetBaseRecipeVersion);
Assert.Null(options.PreparedAssetEffectiveRecipeVersion);
}
}

View file

@ -0,0 +1,209 @@
using System.Collections.Immutable;
using System.Numerics;
using AcDream.Content.Pak;
using AcDream.Core.Physics;
namespace AcDream.Content.Tests;
public sealed class LayeredPreparedAssetSourceTests : IDisposable
{
private static readonly PreparedAssetCatalogIdentity Identity =
new(10, 20, 30, 40, PakFormat.CurrentBakeToolVersion);
private readonly List<string> _paths = [];
[Fact]
public void OverlayWinsAndMissingRenderAndCollisionKeysFallBackToBase()
{
const uint replaced = 0x0100_0001u;
const uint baseOnly = 0x0100_0002u;
string basePath = WritePak(
(replaced, 1f),
(baseOnly, 2f));
string overlayPath = WritePak((replaced, 9f));
using var source = Open(basePath, overlayPath);
PreparedAssetReadResult overlaid =
source.Read(PreparedAssetRequest.GfxObj(replaced));
PreparedAssetReadResult fallback =
source.Read(PreparedAssetRequest.GfxObj(baseOnly));
Assert.Equal(9f, overlaid.Data!.Vertices[0].Position.X);
Assert.Equal(2f, fallback.Data!.Vertices[0].Position.X);
Assert.Equal(
PreparedAssetPresence.Available,
source.Probe(PakAssetType.GfxObjMesh, baseOnly));
PreparedCollisionReadResult<FlatSetupCollision> overlayCollision =
source.ReadSetupCollision(replaced);
PreparedCollisionReadResult<FlatSetupCollision> baseCollision =
source.ReadSetupCollision(baseOnly);
Assert.Equal(9f, overlayCollision.Data!.Height);
Assert.Equal(2f, baseCollision.Data!.Height);
// Replaced reads touch only the overlay; fallback reads touch overlay
// then base. The public counters therefore describe real mapped work.
Assert.Equal(3, source.Stats.Reads);
Assert.Equal(2, source.Stats.Loaded);
Assert.Equal(1, source.Stats.Missing);
Assert.Equal(3, source.CollisionStats.Reads);
Assert.Equal(2, source.CollisionStats.Loaded);
Assert.Equal(1, source.CollisionStats.Missing);
Assert.Equal(
new FileInfo(basePath).Length + new FileInfo(overlayPath).Length,
source.MappedVirtualBytes);
}
[Fact]
public void CorruptOverlayKeyIsAuthoritativeAndNeverFallsBack()
{
const uint fileId = 0x0100_0001u;
string basePath = WriteRenderOnlyPak((fileId, 1f));
string overlayPath = WriteRenderOnlyPak((fileId, 9f));
FlipFirstBlobByte(overlayPath);
using var source = Open(basePath, overlayPath);
PreparedAssetReadResult result =
source.Read(PreparedAssetRequest.GfxObj(fileId));
Assert.Equal(PreparedAssetReadStatus.Corrupt, result.Status);
Assert.Null(result.Data);
Assert.Equal(1, source.Stats.Reads);
Assert.Equal(1, source.Stats.Corrupt);
Assert.Equal(0, source.Stats.Loaded);
Assert.Equal(
PreparedAssetPresence.Corrupt,
source.Probe(PakAssetType.GfxObjMesh, fileId));
}
[Fact]
public void DisposeReleasesBothMappedPackagesAndIsIdempotent()
{
string basePath = WriteRenderOnlyPak((1u, 1f));
string overlayPath = WriteRenderOnlyPak((2u, 2f));
var source = Open(basePath, overlayPath);
source.Dispose();
source.Dispose();
using var baseExclusive = new FileStream(
basePath,
FileMode.Open,
FileAccess.ReadWrite,
FileShare.None);
using var overlayExclusive = new FileStream(
overlayPath,
FileMode.Open,
FileAccess.ReadWrite,
FileShare.None);
Assert.True(baseExclusive.CanWrite);
Assert.True(overlayExclusive.CanWrite);
Assert.Throws<ObjectDisposedException>(() => _ = source.Stats);
}
private LayeredPreparedAssetSource Open(string basePath, string overlayPath) =>
new(
new PakPreparedAssetSource(basePath, Identity),
new PakPreparedAssetSource(overlayPath, Identity));
private string WritePak(params (uint FileId, float Marker)[] entries)
{
string path = NewPath();
using var writer = new PakWriter(path, Header());
foreach ((uint fileId, float marker) in entries)
{
writer.AddBlob(
PakKey.Compose(PakAssetType.GfxObjMesh, fileId),
Mesh(fileId, marker));
writer.AddBlob(
PakKey.Compose(PakAssetType.SetupCollision, fileId),
FlatCollisionAssetSerializer.Serialize(Setup(marker)));
}
writer.Finish();
return path;
}
private string WriteRenderOnlyPak(params (uint FileId, float Marker)[] entries)
{
string path = NewPath();
using var writer = new PakWriter(path, Header());
foreach ((uint fileId, float marker) in entries)
{
writer.AddBlob(
PakKey.Compose(PakAssetType.GfxObjMesh, fileId),
Mesh(fileId, marker));
}
writer.Finish();
return path;
}
private string NewPath()
{
string path = Path.Combine(
Path.GetTempPath(),
$"acdream-layered-prepared-{Guid.NewGuid():N}.pak");
_paths.Add(path);
return path;
}
private static PakHeader Header() =>
new()
{
PortalIteration = Identity.PortalIteration,
CellIteration = Identity.CellIteration,
HighResIteration = Identity.HighResIteration,
LanguageIteration = Identity.LanguageIteration,
BakeToolVersion = Identity.BakeToolVersion,
};
private static ObjectMeshData Mesh(uint objectId, float marker) =>
new()
{
ObjectId = objectId,
Vertices =
[
new(
new Vector3(marker, 2, 3),
Vector3.UnitZ,
new Vector2(0.25f, 0.75f)),
],
};
private static FlatSetupCollision Setup(float marker) =>
new(
ImmutableArray<FlatCollisionCylinder>.Empty,
ImmutableArray<FlatCollisionSphere>.Empty,
marker,
marker,
marker,
marker);
private static void FlipFirstBlobByte(string path)
{
using var stream = new FileStream(
path,
FileMode.Open,
FileAccess.ReadWrite,
FileShare.None);
stream.Position = PakHeader.Size + 4;
int value = stream.ReadByte();
Assert.NotEqual(-1, value);
stream.Position = PakHeader.Size + 4;
stream.WriteByte((byte)(value ^ 0xFF));
}
public void Dispose()
{
foreach (string path in _paths)
{
try
{
File.Delete(path);
}
catch
{
// Best-effort test cleanup.
}
}
}
}

View file

@ -87,6 +87,27 @@ public sealed class SessionConfigurationSharedFixtureTests
Assert.Empty(session.Plugins);
}
[Fact]
public void HeadlessReaderAcceptsOptionalLayeredPreparedContent()
{
string json = LauncherCoreSessionConfigFixture.Compose();
json = json.Replace(
"\"preparedAssetPath\": \"composer-dats/acdream.pak\"",
"\"preparedAssetPath\": \"composer-dats/acdream.pak\",\n"
+ " \"preparedAssetOverlayPath\": \"composer-dats/update.pak\",\n"
+ " \"preparedAssetBaseRecipeVersion\": 4,\n"
+ " \"preparedAssetEffectiveRecipeVersion\": 5",
StringComparison.Ordinal);
using TemporaryFile file = TemporaryFile.Create(json);
HeadlessContentDescriptor content = Assert.IsType<HeadlessContentDescriptor>(
HeadlessConfigurationLoader.Load(file.Path).Process.Content);
Assert.Equal("composer-dats/update.pak", content.PreparedAssetOverlayPath);
Assert.Equal(4u, content.PreparedAssetBaseRecipeVersion);
Assert.Equal(5u, content.PreparedAssetEffectiveRecipeVersion);
}
[Fact]
public void HeadlessReaderAcceptsTheProductionShapedSharedFixture()
{

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -39,6 +39,34 @@ public sealed class SessionConfigComposerTests
LoginCommands = ["/tell someone, hi"],
};
[Fact]
public void PendingClientCompatibilityCannotComposePlayOrProbeSession()
{
LauncherInstallRecord pending = Install with
{
RequiresClientCompatibilityConfirmation = true,
};
InvalidOperationException play = Assert.Throws<InvalidOperationException>(() =>
SessionConfigComposer.Compose(
Server(),
Account(),
Character(LaunchMode.Gui),
pending,
Paths,
"pending-play"));
InvalidOperationException probe = Assert.Throws<InvalidOperationException>(() =>
SessionConfigComposer.ComposeProbe(
Server(),
Account(),
pending,
Paths,
"pending-probe"));
Assert.Contains("matching client", play.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("matching client", probe.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void GuiModeIncludesCharacterSelectorAndOmitsPolicy()
{

View file

@ -152,6 +152,27 @@ public sealed class LauncherUpdateViewModelTests
Assert.Equal(0, updater.StageCalls);
}
[Fact]
public async Task AnotherStartupQuestionDefersButDoesNotLoseTheUpdatePrompt()
{
var updater = new FakeUpdater { ClientUpdateAvailable = true };
bool canOpen = false;
using var viewModel = new LauncherUpdateViewModel(
updater,
new ImmediateUiDispatcher(),
() => { },
canOpen: () => canOpen,
canMutate: () => true);
await viewModel.StartupCheckAsync();
Assert.False(viewModel.IsOpen);
canOpen = true;
viewModel.TryOpenPendingUpdate();
Assert.True(viewModel.IsOpen);
}
[Fact]
public async Task UpdatingIsRefusedWhileASessionIsRunning()
{

View file

@ -2,6 +2,7 @@ using AcDream.Launcher.Core.Orchestration;
using AcDream.Launcher.Core.Profiles;
using AcDream.Launcher.Core.Launching;
using AcDream.Launcher.Core.Installation;
using AcDream.Launcher.Core.Updates;
using AcDream.Launcher.ViewModels;
namespace AcDream.Launcher.Tests;
@ -9,7 +10,7 @@ namespace AcDream.Launcher.Tests;
public sealed class LauncherWindowViewModelTests
{
[Fact]
public void InitializeProjectsHierarchySessionsAndFutureWorkflowShells()
public async Task InitializeProjectsHierarchySessionsAndFutureWorkflowShells()
{
using var orchestrator = new FakeLauncherOrchestrator();
using var viewModel = new LauncherWindowViewModel(
@ -35,6 +36,11 @@ public sealed class LauncherWindowViewModelTests
Assert.Equal("Character select", session.State);
Assert.True(session.IsActive);
Assert.True(viewModel.IsInstallationChecking);
Assert.True(viewModel.ShowInstallationBanner);
Assert.False(viewModel.IsFirstRunRequired);
await viewModel.StartBackgroundInitializationAsync();
Assert.False(viewModel.IsInstallationChecking);
Assert.True(viewModel.IsFirstRunRequired);
Assert.Contains("SHA-256", viewModel.FirstRunWizardShell.Body, StringComparison.Ordinal);
// LU3: the update question says nothing and shows nothing until the
@ -47,6 +53,254 @@ public sealed class LauncherWindowViewModelTests
Assert.False(viewModel.FirstRunWizardShell.IsOpen);
}
[Fact]
public async Task BackgroundStartupWaitsForWindowSignalAndOrdersContentBeforeUpdates()
{
using var orchestrator = new FakeLauncherOrchestrator
{
Session = FakeLauncherOrchestrator.CreateSession(
LauncherActivityState.Exited,
"Exited cleanly."),
};
var contentCompletion = new TaskCompletionSource<InstallRecordVerification>(
TaskCreationOptions.RunContinuationsAsynchronously);
var installer = new FakeLauncherInstaller
{
LoadExistingHandler = _ => contentCompletion.Task,
};
var updater = new StartupOrderUpdater();
using var viewModel = new LauncherWindowViewModel(
orchestrator,
new ImmediateUiDispatcher(),
installer,
updater);
viewModel.Initialize();
// App calls the next method only from MainWindow.Opened. Constructing
// and initializing the shell cannot start pak I/O or update recovery.
Assert.Empty(installer.LoadExistingCalls);
Assert.Equal(0, updater.InitializeCalls);
Assert.Equal(0, updater.CheckCalls);
Assert.True(viewModel.IsInstallationChecking);
Task startup = viewModel.StartBackgroundInitializationAsync();
Assert.Equal([false], installer.LoadExistingCalls);
Assert.Equal(0, updater.InitializeCalls);
Assert.Equal(0, updater.CheckCalls);
Assert.False(startup.IsCompleted);
contentCompletion.SetResult(new InstallRecordVerification(
InstallRecordVerificationState.Verified,
installer.Record,
"Client content verified without a startup hash."));
await startup;
Assert.Equal(installer.Record, orchestrator.InstalledRecord);
Assert.False(viewModel.IsInstallationChecking);
Assert.False(viewModel.ShowInstallationBanner);
Assert.Equal(1, updater.InitializeCalls);
Assert.Equal(1, updater.CheckCalls);
Assert.Same(startup, viewModel.StartBackgroundInitializationAsync());
}
[Fact]
public async Task RequiredWorldDataWorkIsExplainedAndNeverStartsWithoutConfirmation()
{
using var orchestrator = new FakeLauncherOrchestrator
{
Session = FakeLauncherOrchestrator.CreateSession(
LauncherActivityState.Exited,
"Exited cleanly."),
};
var installer = new FakeLauncherInstaller();
var stale = installer.Record with
{
BakeToolVersion = LauncherInstallRecordStore.CurrentBakeToolVersion - 1,
PreparedAssetSize = 28L * 1024 * 1024 * 1024,
};
ContentMigrationPlan migration = ContentMigrationCatalog.Resolve(
stale.BakeToolVersion,
LauncherInstallRecordStore.CurrentBakeToolVersion);
installer.NextVerification = new InstallRecordVerification(
InstallRecordVerificationState.ContentUpdateRequired,
stale,
$"World data update required: {migration.Reason}.",
migration);
using var viewModel = new LauncherWindowViewModel(
orchestrator,
new ImmediateUiDispatcher(),
installer,
new StartupOrderUpdater());
viewModel.Initialize();
await viewModel.StartBackgroundInitializationAsync();
Assert.Same(stale, orchestrator.InstalledRecord);
Assert.True(viewModel.FirstRunWizardShell.IsOpen);
Assert.True(viewModel.FirstRunWizardShell.IsContentUpdate);
Assert.Equal("World data update required", viewModel.InstallationBannerTitle);
Assert.Contains("complete replacement pak", viewModel.FirstRunWizardShell.Body);
Assert.Contains("existing package stays", viewModel.FirstRunWizardShell.Body);
Assert.Equal("Rebuild world data", viewModel.FirstRunWizardShell.StartActionText);
Assert.Null(installer.InstallRequest);
viewModel.FirstRunWizardShell.CloseCommand.Execute(null);
Assert.Null(installer.InstallRequest);
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
await viewModel.FirstRunWizardShell.StartCommand.ExecuteAsync();
Assert.NotNull(installer.InstallRequest);
Assert.Equal(installer.Record, orchestrator.InstalledRecord);
Assert.True(viewModel.FirstRunWizardShell.IsCompleted);
}
[Fact]
public async Task PreparedWorldDataWaitsForMatchingClientAndNotNowCannotPublishIt()
{
using var orchestrator = new FakeLauncherOrchestrator
{
Session = FakeLauncherOrchestrator.CreateSession(
LauncherActivityState.Exited,
"Exited cleanly."),
};
var installer = new FakeLauncherInstaller();
var stale = installer.Record with
{
BakeToolVersion = LauncherInstallRecordStore.CurrentBakeToolVersion - 1,
};
ContentMigrationPlan migration = ContentMigrationCatalog.Resolve(
stale.BakeToolVersion,
LauncherInstallRecordStore.CurrentBakeToolVersion);
installer.NextVerification = new InstallRecordVerification(
InstallRecordVerificationState.ContentUpdateRequired,
stale,
$"World data update required: {migration.Reason}.",
migration);
var updater = new StartupOrderUpdater { ClientUpdateAvailable = true };
using var viewModel = new LauncherWindowViewModel(
orchestrator,
new ImmediateUiDispatcher(),
installer,
updater);
viewModel.Initialize();
await viewModel.StartBackgroundInitializationAsync();
await viewModel.FirstRunWizardShell.StartCommand.ExecuteAsync();
Assert.Null(orchestrator.InstalledRecord);
Assert.Contains(
"Play stays disabled",
viewModel.FirstRunWizardShell.CompletedBody,
StringComparison.Ordinal);
Assert.Contains(
"matching game update",
orchestrator.InstallationStatus,
StringComparison.OrdinalIgnoreCase);
viewModel.FirstRunWizardShell.AcknowledgeCompletionCommand.Execute(null);
Assert.True(viewModel.UpdatePrompt.IsOpen);
viewModel.UpdatePrompt.NotNowCommand.Execute(null);
Assert.Null(orchestrator.InstalledRecord);
Assert.Equal("Game update required", viewModel.InstallationBannerTitle);
viewModel.UpdatePrompt.TryOpenPendingUpdate();
await viewModel.UpdatePrompt.UpdateCommand.ExecuteAsync();
Assert.Equal(1, updater.InstallCalls);
Assert.Equal(installer.Record, orchestrator.InstalledRecord);
Assert.False(viewModel.ShowInstallationBanner);
}
[Fact]
public async Task ContentBuiltDuringStartupWaitsForCompatibilityCheckResult()
{
using var orchestrator = new FakeLauncherOrchestrator
{
Session = FakeLauncherOrchestrator.CreateSession(
LauncherActivityState.Exited,
"Exited cleanly."),
};
var installer = new FakeLauncherInstaller();
var stale = installer.Record with
{
BakeToolVersion = LauncherInstallRecordStore.CurrentBakeToolVersion - 1,
};
ContentMigrationPlan migration = ContentMigrationCatalog.Resolve(
stale.BakeToolVersion,
LauncherInstallRecordStore.CurrentBakeToolVersion);
installer.NextVerification = new InstallRecordVerification(
InstallRecordVerificationState.ContentUpdateRequired,
stale,
$"World data update required: {migration.Reason}.",
migration);
var checkGate = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
var updater = new StartupOrderUpdater { CheckGate = checkGate };
using var viewModel = new LauncherWindowViewModel(
orchestrator,
new ImmediateUiDispatcher(),
installer,
updater);
viewModel.Initialize();
Task startup = viewModel.StartBackgroundInitializationAsync();
Assert.True(viewModel.FirstRunWizardShell.IsOpen);
Assert.False(startup.IsCompleted);
await viewModel.FirstRunWizardShell.StartCommand.ExecuteAsync();
Assert.Null(orchestrator.InstalledRecord);
checkGate.SetResult(true);
await startup;
Assert.Equal(installer.Record, orchestrator.InstalledRecord);
Assert.Contains(
"play now",
viewModel.FirstRunWizardShell.CompletedBody,
StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task RestartRestoresPendingClientGateUntilClientInstallCompletes()
{
using var orchestrator = new FakeLauncherOrchestrator
{
Session = FakeLauncherOrchestrator.CreateSession(
LauncherActivityState.Exited,
"Exited cleanly."),
};
var installer = new FakeLauncherInstaller();
LauncherInstallRecord gatedRecord = installer.Record with
{
RequiresClientCompatibilityConfirmation = true,
};
installer.NextVerification = new InstallRecordVerification(
InstallRecordVerificationState.Verified,
gatedRecord,
"World data is verified; matching client confirmation is pending.");
var updater = new StartupOrderUpdater { ClientUpdateAvailable = true };
using var viewModel = new LauncherWindowViewModel(
orchestrator,
new ImmediateUiDispatcher(),
installer,
updater);
viewModel.Initialize();
await viewModel.StartBackgroundInitializationAsync();
Assert.Null(orchestrator.InstalledRecord);
Assert.Equal("Game update required", viewModel.InstallationBannerTitle);
Assert.True(viewModel.UpdatePrompt.IsOpen);
await viewModel.UpdatePrompt.UpdateCommand.ExecuteAsync();
Assert.Equal(1, installer.ConfirmCompatibilityCalls);
Assert.NotNull(orchestrator.InstalledRecord);
Assert.False(
orchestrator.InstalledRecord!.RequiresClientCompatibilityConfirmation);
}
[Fact]
public void ProfileCommandsExposeServerAccountCharacterCrudDialogsAndClearPasswords()
{
@ -336,8 +590,8 @@ public sealed class LauncherWindowViewModelTests
var installer = new FakeLauncherInstaller();
using var viewModel = CreateInitialized(orchestrator, installer);
// LU1: nothing has asked for a verification yet. Startup's own
// verification happens in the composition root, not here.
// Content discovery is armed by MainWindow.Opened, which this focused
// view-model setup deliberately has not signalled.
Assert.Empty(installer.LoadExistingCalls);
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
@ -659,6 +913,9 @@ public sealed class LauncherWindowViewModelTests
public LauncherInstallRecord? InstalledRecord { get; private set; }
public string InstallationStatus { get; private set; } =
"No installed client is configured.";
public void LoadProfiles() => LoadCalled = true;
public LauncherStateSnapshot GetSnapshot() => new(
@ -666,9 +923,7 @@ public sealed class LauncherWindowViewModelTests
[Session],
Platform,
IsInstallationReady: InstalledRecord is not null,
InstallationStatus: InstalledRecord is null
? "No installed client is configured."
: "Client content verified.");
InstallationStatus);
public LauncherCapability GetLaunchCapability(LaunchMode mode) =>
Platform.ForLaunchMode(mode);
@ -685,6 +940,18 @@ public sealed class LauncherWindowViewModelTests
public void SetInstallRecord(LauncherInstallRecord? installRecord)
{
InstalledRecord = installRecord;
InstallationStatus = installRecord is null
? "No installed client is configured."
: "Client content verified.";
StateChanged?.Invoke(this, EventArgs.Empty);
}
public void SetInstallationState(
LauncherInstallRecord? installRecord,
string installationStatus)
{
InstalledRecord = installRecord;
InstallationStatus = installationStatus;
StateChanged?.Invoke(this, EventArgs.Empty);
}
@ -836,6 +1103,8 @@ public sealed class LauncherWindowViewModelTests
public (string DatDirectory, int Threads)? InstallRequest { get; private set; }
public int ConfirmCompatibilityCalls { get; private set; }
public Func<
string,
int,
@ -884,12 +1153,17 @@ public sealed class LauncherWindowViewModelTests
/// called with, oldest first.</summary>
public List<bool> LoadExistingCalls { get; } = [];
public Func<CancellationToken, Task<InstallRecordVerification>>?
LoadExistingHandler { get; set; }
public Task<InstallRecordVerification> LoadExistingAsync(
CancellationToken cancellationToken = default,
bool forceFullVerification = false)
{
LoadExistingCalls.Add(forceFullVerification);
return Task.FromResult(NextVerification);
return LoadExistingHandler is null
? Task.FromResult(NextVerification)
: LoadExistingHandler(cancellationToken);
}
public Task<LauncherInstallResult> InstallAsync(
@ -919,5 +1193,88 @@ public sealed class LauncherWindowViewModelTests
"Verifying package."));
return Task.FromResult(new LauncherInstallResult(Record));
}
public void ConfirmClientCompatibility() => ConfirmCompatibilityCalls++;
}
private sealed class StartupOrderUpdater : ILauncherUpdater
{
private static readonly LauncherVersion Version = LauncherVersion.Parse("1.0.0");
private readonly ClientVersionResolution _resolution = new(
ClientVersionState.Missing,
"No versioned client is installed.",
null,
null,
null,
null);
public int InitializeCalls { get; private set; }
public int CheckCalls { get; private set; }
public int InstallCalls { get; private set; }
public bool ClientUpdateAvailable { get; init; }
public TaskCompletionSource<bool>? CheckGate { get; init; }
public ClientVersionResolution CurrentClient => _resolution;
public Task<ClientVersionResolution> InitializeAsync(
CancellationToken cancellationToken = default)
{
InitializeCalls++;
return Task.FromResult(_resolution);
}
public async Task<LauncherUpdateCheckResult> CheckAsync(
CancellationToken cancellationToken = default)
{
CheckCalls++;
if (CheckGate is not null)
{
_ = await CheckGate.Task.WaitAsync(cancellationToken);
}
var artifact = new ReleaseArtifact(
new Uri("https://updates.example.test/acdream.zip"),
new string('a', 64),
1);
var manifest = new ReleaseManifest(
Version,
Version,
new Dictionary<string, ReleaseArtifact> { ["win-x64"] = artifact },
new Dictionary<string, ReleaseArtifact> { ["win-x64"] = artifact });
return new LauncherUpdateCheckResult(
manifest,
"win-x64",
Version,
Version,
IsClientUpdateAvailable: ClientUpdateAvailable,
IsLauncherUpdateAvailable: false,
IsLauncherMinimumSatisfied: true,
"Everything is current.");
}
public Task<ClientVersionResolution> InstallClientAsync(
LauncherUpdateCheckResult check,
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
InstallCalls++;
return Task.FromResult(_resolution);
}
public Task<SelfUpdateStageResult> StageLauncherAsync(
LauncherUpdateCheckResult check,
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<ClientVersionResolution> RollbackClientAsync(
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
}
}