feat(launcher): stabilize prepared content updates
This commit is contained in:
parent
f160f3fee1
commit
af9327a17b
42 changed files with 3706 additions and 147 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 1–3; bake-tool 4 appends typed GfxObj,
|
||||
Setup, CellStruct, and EnvCell-topology collision payloads. Core owns the
|
||||
|
|
|
|||
230
docs/plans/2026-08-25-launcher-content-stabilization.md
Normal file
230
docs/plans/2026-08-25-launcher-content-stabilization.md
Normal 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.
|
||||
|
|
@ -51,6 +51,9 @@ internal sealed record ContentEffectsAudioResult(
|
|||
internal sealed record ContentEffectsAudioDependencies(
|
||||
string DatDirectory,
|
||||
string PreparedAssetPath,
|
||||
string? PreparedAssetOverlayPath,
|
||||
uint? PreparedAssetBaseRecipeVersion,
|
||||
uint? PreparedAssetEffectiveRecipeVersion,
|
||||
ResidencyBudgetOptions ResidencyBudgets,
|
||||
PhysicsDataCache PhysicsDataCache,
|
||||
bool DumpMotionEnabled,
|
||||
|
|
@ -99,6 +102,9 @@ internal interface IContentEffectsAudioCompositionFactory
|
|||
IDatReaderWriter OpenDatCollection(string datDirectory);
|
||||
IPreparedAssetSource OpenPreparedAssetSource(
|
||||
string path,
|
||||
string? overlayPath,
|
||||
uint? baseRecipeVersion,
|
||||
uint? effectiveRecipeVersion,
|
||||
IDatReaderWriter dats,
|
||||
Action<string> diagnostic);
|
||||
MagicCatalog LoadMagicCatalog(IDatReaderWriter dats);
|
||||
|
|
@ -169,9 +175,54 @@ internal sealed class RetailContentEffectsAudioCompositionFactory
|
|||
|
||||
public IPreparedAssetSource OpenPreparedAssetSource(
|
||||
string path,
|
||||
string? overlayPath,
|
||||
uint? baseRecipeVersion,
|
||||
uint? effectiveRecipeVersion,
|
||||
IDatReaderWriter dats,
|
||||
Action<string> diagnostic) =>
|
||||
new PakPreparedAssetSource(path, dats, diagnostic);
|
||||
Action<string> diagnostic)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(overlayPath))
|
||||
{
|
||||
return new PakPreparedAssetSource(path, dats, diagnostic);
|
||||
}
|
||||
|
||||
if (baseRecipeVersion is not > 0
|
||||
|| effectiveRecipeVersion is not > 0)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"Layered prepared content is missing its recipe identities.");
|
||||
}
|
||||
|
||||
if (effectiveRecipeVersion
|
||||
!= AcDream.Content.Pak.PakFormat.CurrentBakeToolVersion)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Prepared content recipe {effectiveRecipeVersion} does not "
|
||||
+ $"match client recipe "
|
||||
+ $"{AcDream.Content.Pak.PakFormat.CurrentBakeToolVersion}.");
|
||||
}
|
||||
|
||||
PakPreparedAssetSource? baseSource = null;
|
||||
PakPreparedAssetSource? overlaySource = null;
|
||||
try
|
||||
{
|
||||
baseSource = new PakPreparedAssetSource(
|
||||
path,
|
||||
PreparedAssetCatalogIdentity.From(dats, baseRecipeVersion.Value),
|
||||
diagnostic);
|
||||
overlaySource = new PakPreparedAssetSource(
|
||||
overlayPath,
|
||||
PreparedAssetCatalogIdentity.From(dats, effectiveRecipeVersion.Value),
|
||||
diagnostic);
|
||||
return new LayeredPreparedAssetSource(baseSource, overlaySource);
|
||||
}
|
||||
catch
|
||||
{
|
||||
overlaySource?.Dispose();
|
||||
baseSource?.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public MagicCatalog LoadMagicCatalog(IDatReaderWriter dats) =>
|
||||
MagicCatalog.Load(dats);
|
||||
|
|
@ -370,6 +421,9 @@ internal sealed class ContentEffectsAudioCompositionPhase :
|
|||
"prepared asset source",
|
||||
() => _factory.OpenPreparedAssetSource(
|
||||
_dependencies.PreparedAssetPath,
|
||||
_dependencies.PreparedAssetOverlayPath,
|
||||
_dependencies.PreparedAssetBaseRecipeVersion,
|
||||
_dependencies.PreparedAssetEffectiveRecipeVersion,
|
||||
dats,
|
||||
_dependencies.Error),
|
||||
static value => value.Dispose()).Publish(
|
||||
|
|
|
|||
|
|
@ -71,6 +71,12 @@ internal sealed class SessionContentDescriptor
|
|||
|
||||
[JsonRequired]
|
||||
public string PreparedAssetPath { get; init; } = string.Empty;
|
||||
|
||||
public string? PreparedAssetOverlayPath { get; init; }
|
||||
|
||||
public uint? PreparedAssetBaseRecipeVersion { get; init; }
|
||||
|
||||
public uint? PreparedAssetEffectiveRecipeVersion { get; init; }
|
||||
}
|
||||
|
||||
internal sealed record SessionDescriptor
|
||||
|
|
|
|||
|
|
@ -83,6 +83,18 @@ internal static class SessionConfigurationLoader
|
|||
throw new SessionConfigurationException(
|
||||
"process.content requires non-empty datDirectory and preparedAssetPath.");
|
||||
}
|
||||
|
||||
bool hasOverlay = !string.IsNullOrWhiteSpace(
|
||||
content.PreparedAssetOverlayPath);
|
||||
bool hasBaseRecipe = content.PreparedAssetBaseRecipeVersion is > 0;
|
||||
bool hasEffectiveRecipe =
|
||||
content.PreparedAssetEffectiveRecipeVersion is > 0;
|
||||
if (hasOverlay != hasBaseRecipe || hasOverlay != hasEffectiveRecipe)
|
||||
{
|
||||
throw new SessionConfigurationException(
|
||||
"process.content overlay path, base recipe, and effective recipe "
|
||||
+ "must be supplied together.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateSession(SessionDescriptor session)
|
||||
|
|
|
|||
|
|
@ -1418,6 +1418,9 @@ public sealed class GameWindow :
|
|||
new ContentEffectsAudioDependencies(
|
||||
_datDir,
|
||||
_options.PreparedAssetPath,
|
||||
_options.PreparedAssetOverlayPath,
|
||||
_options.PreparedAssetBaseRecipeVersion,
|
||||
_options.PreparedAssetEffectiveRecipeVersion,
|
||||
_options.ResidencyBudgets,
|
||||
_physicsDataCache,
|
||||
_animationDiagnostics.DumpMotionEnabled,
|
||||
|
|
|
|||
|
|
@ -116,6 +116,12 @@ public sealed record RuntimeOptions(
|
|||
/// <see cref="LoginCommands"/>, milliseconds.</summary>
|
||||
int LoginCommandDelayMs)
|
||||
{
|
||||
public string? PreparedAssetOverlayPath { get; init; }
|
||||
|
||||
public uint? PreparedAssetBaseRecipeVersion { get; init; }
|
||||
|
||||
public uint? PreparedAssetEffectiveRecipeVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Build options from the process environment. Used by
|
||||
/// <c>Program.cs</c> at startup.
|
||||
|
|
@ -272,6 +278,12 @@ public sealed record RuntimeOptions(
|
|||
{
|
||||
PreparedAssetPath = NullIfEmpty(content?.PreparedAssetPath)
|
||||
?? baseOptions.PreparedAssetPath,
|
||||
PreparedAssetOverlayPath =
|
||||
NullIfEmpty(content?.PreparedAssetOverlayPath),
|
||||
PreparedAssetBaseRecipeVersion =
|
||||
content?.PreparedAssetBaseRecipeVersion,
|
||||
PreparedAssetEffectiveRecipeVersion =
|
||||
content?.PreparedAssetEffectiveRecipeVersion,
|
||||
LiveMode = true,
|
||||
// Campaign LA gate round 2: a session-config launch IS a product
|
||||
// launch — the retail UI is the shipped UI, not a dev option.
|
||||
|
|
|
|||
|
|
@ -93,14 +93,24 @@ public readonly record struct PreparedAssetCatalogIdentity(
|
|||
uint BakeToolVersion)
|
||||
{
|
||||
public static PreparedAssetCatalogIdentity From(IDatReaderWriter dats)
|
||||
=> From(dats, PakFormat.CurrentBakeToolVersion);
|
||||
|
||||
public static PreparedAssetCatalogIdentity From(
|
||||
IDatReaderWriter dats,
|
||||
uint bakeToolVersion)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
if (bakeToolVersion == 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(bakeToolVersion));
|
||||
}
|
||||
|
||||
return new(
|
||||
checked((uint)dats.PortalIteration),
|
||||
checked((uint)dats.CellIteration),
|
||||
checked((uint)dats.HighResIteration),
|
||||
checked((uint)dats.LanguageIteration),
|
||||
PakFormat.CurrentBakeToolVersion);
|
||||
bakeToolVersion);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
239
src/AcDream.Content/LayeredPreparedAssetSource.cs
Normal file
239
src/AcDream.Content/LayeredPreparedAssetSource.cs
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
using AcDream.Content.Pak;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.Content;
|
||||
|
||||
/// <summary>
|
||||
/// One cumulative overlay in front of one complete base package. Missing keys
|
||||
/// fall through; an overlay key that exists but is corrupt is authoritative and
|
||||
/// never hides its corruption behind older base bytes. Render and collision
|
||||
/// payloads use the exact same rule and the two package owners are disposed as
|
||||
/// one content set.
|
||||
/// </summary>
|
||||
public sealed class LayeredPreparedAssetSource :
|
||||
IPreparedAssetSource,
|
||||
IPreparedCollisionSource
|
||||
{
|
||||
private IPreparedAssetSource? _baseAssets;
|
||||
private IPreparedAssetSource? _overlayAssets;
|
||||
private IPreparedCollisionSource? _baseCollision;
|
||||
private IPreparedCollisionSource? _overlayCollision;
|
||||
|
||||
public LayeredPreparedAssetSource(
|
||||
IPreparedAssetSource baseSource,
|
||||
IPreparedAssetSource overlaySource)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(baseSource);
|
||||
ArgumentNullException.ThrowIfNull(overlaySource);
|
||||
if (ReferenceEquals(baseSource, overlaySource))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The base and overlay must have independent owners.",
|
||||
nameof(overlaySource));
|
||||
}
|
||||
|
||||
_baseCollision = baseSource as IPreparedCollisionSource
|
||||
?? throw new ArgumentException(
|
||||
"The base source must expose prepared collision payloads.",
|
||||
nameof(baseSource));
|
||||
_overlayCollision = overlaySource as IPreparedCollisionSource
|
||||
?? throw new ArgumentException(
|
||||
"The overlay source must expose prepared collision payloads.",
|
||||
nameof(overlaySource));
|
||||
_baseAssets = baseSource;
|
||||
_overlayAssets = overlaySource;
|
||||
}
|
||||
|
||||
public PreparedAssetSourceStats Stats
|
||||
{
|
||||
get
|
||||
{
|
||||
IPreparedAssetSource baseSource = Require(_baseAssets);
|
||||
IPreparedAssetSource overlay = Require(_overlayAssets);
|
||||
PreparedAssetSourceStats left = baseSource.Stats;
|
||||
PreparedAssetSourceStats right = overlay.Stats;
|
||||
return new(
|
||||
left.Probes + right.Probes,
|
||||
left.Reads + right.Reads,
|
||||
left.Loaded + right.Loaded,
|
||||
left.Missing + right.Missing,
|
||||
left.Corrupt + right.Corrupt);
|
||||
}
|
||||
}
|
||||
|
||||
public PreparedCollisionSourceStats CollisionStats
|
||||
{
|
||||
get
|
||||
{
|
||||
IPreparedCollisionSource baseSource = Require(_baseCollision);
|
||||
IPreparedCollisionSource overlay = Require(_overlayCollision);
|
||||
PreparedCollisionSourceStats left = baseSource.CollisionStats;
|
||||
PreparedCollisionSourceStats right = overlay.CollisionStats;
|
||||
return new(
|
||||
left.Probes + right.Probes,
|
||||
left.Reads + right.Reads,
|
||||
left.Loaded + right.Loaded,
|
||||
left.Missing + right.Missing,
|
||||
left.Corrupt + right.Corrupt);
|
||||
}
|
||||
}
|
||||
|
||||
public CacheStats DecodedTextureCacheStats
|
||||
{
|
||||
get
|
||||
{
|
||||
CacheStats left = Require(_baseAssets).DecodedTextureCacheStats;
|
||||
CacheStats right = Require(_overlayAssets).DecodedTextureCacheStats;
|
||||
return new(
|
||||
left.Hits + right.Hits,
|
||||
left.Misses + right.Misses,
|
||||
left.Evictions + right.Evictions);
|
||||
}
|
||||
}
|
||||
|
||||
public long MappedVirtualBytes =>
|
||||
checked(
|
||||
Require(_baseAssets).MappedVirtualBytes
|
||||
+ Require(_overlayAssets).MappedVirtualBytes);
|
||||
|
||||
public PreparedAssetPresence Probe(PakAssetType type, uint sourceFileId)
|
||||
{
|
||||
PreparedAssetPresence overlay =
|
||||
Require(_overlayAssets).Probe(type, sourceFileId);
|
||||
return overlay == PreparedAssetPresence.Missing
|
||||
? Require(_baseAssets).Probe(type, sourceFileId)
|
||||
: overlay;
|
||||
}
|
||||
|
||||
public PreparedAssetReadResult Read(
|
||||
in PreparedAssetRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
PreparedAssetReadResult overlay =
|
||||
Require(_overlayAssets).Read(request, cancellationToken);
|
||||
return overlay.Status == PreparedAssetReadStatus.Missing
|
||||
? Require(_baseAssets).Read(request, cancellationToken)
|
||||
: overlay;
|
||||
}
|
||||
|
||||
public PreparedAssetPresence ProbeCollision(
|
||||
PakAssetType type,
|
||||
uint sourceFileId)
|
||||
{
|
||||
PreparedAssetPresence overlay =
|
||||
Require(_overlayCollision).ProbeCollision(type, sourceFileId);
|
||||
return overlay == PreparedAssetPresence.Missing
|
||||
? Require(_baseCollision).ProbeCollision(type, sourceFileId)
|
||||
: overlay;
|
||||
}
|
||||
|
||||
public PreparedCollisionReadResult<FlatGfxObjCollisionAsset>
|
||||
ReadGfxObjCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
PreparedCollisionReadResult<FlatGfxObjCollisionAsset> overlay =
|
||||
Require(_overlayCollision).ReadGfxObjCollision(
|
||||
sourceFileId,
|
||||
cancellationToken);
|
||||
return overlay.Status == PreparedAssetReadStatus.Missing
|
||||
? Require(_baseCollision).ReadGfxObjCollision(
|
||||
sourceFileId,
|
||||
cancellationToken)
|
||||
: overlay;
|
||||
}
|
||||
|
||||
public PreparedCollisionReadResult<FlatSetupCollision>
|
||||
ReadSetupCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
PreparedCollisionReadResult<FlatSetupCollision> overlay =
|
||||
Require(_overlayCollision).ReadSetupCollision(
|
||||
sourceFileId,
|
||||
cancellationToken);
|
||||
return overlay.Status == PreparedAssetReadStatus.Missing
|
||||
? Require(_baseCollision).ReadSetupCollision(
|
||||
sourceFileId,
|
||||
cancellationToken)
|
||||
: overlay;
|
||||
}
|
||||
|
||||
public PreparedCollisionReadResult<FlatCellStructureCollisionAsset>
|
||||
ReadCellStructureCollision(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
PreparedCollisionReadResult<FlatCellStructureCollisionAsset> overlay =
|
||||
Require(_overlayCollision).ReadCellStructureCollision(
|
||||
sourceFileId,
|
||||
cancellationToken);
|
||||
return overlay.Status == PreparedAssetReadStatus.Missing
|
||||
? Require(_baseCollision).ReadCellStructureCollision(
|
||||
sourceFileId,
|
||||
cancellationToken)
|
||||
: overlay;
|
||||
}
|
||||
|
||||
public PreparedCollisionReadResult<FlatEnvCellTopology>
|
||||
ReadEnvCellTopology(
|
||||
uint sourceFileId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
PreparedCollisionReadResult<FlatEnvCellTopology> overlay =
|
||||
Require(_overlayCollision).ReadEnvCellTopology(
|
||||
sourceFileId,
|
||||
cancellationToken);
|
||||
return overlay.Status == PreparedAssetReadStatus.Missing
|
||||
? Require(_baseCollision).ReadEnvCellTopology(
|
||||
sourceFileId,
|
||||
cancellationToken)
|
||||
: overlay;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
IPreparedAssetSource? overlay = Interlocked.Exchange(
|
||||
ref _overlayAssets,
|
||||
null);
|
||||
IPreparedAssetSource? baseSource = Interlocked.Exchange(
|
||||
ref _baseAssets,
|
||||
null);
|
||||
_overlayCollision = null;
|
||||
_baseCollision = null;
|
||||
|
||||
List<Exception>? failures = null;
|
||||
DisposeOne(overlay, ref failures);
|
||||
DisposeOne(baseSource, ref failures);
|
||||
if (failures is { Count: > 0 })
|
||||
{
|
||||
throw new AggregateException(
|
||||
"One or more prepared-content layers failed to dispose.",
|
||||
failures);
|
||||
}
|
||||
}
|
||||
|
||||
private static T Require<T>(T? value)
|
||||
where T : class =>
|
||||
value ?? throw new ObjectDisposedException(
|
||||
nameof(LayeredPreparedAssetSource));
|
||||
|
||||
private static void DisposeOne(
|
||||
IDisposable? value,
|
||||
ref List<Exception>? failures)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
value.Dispose();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
(failures ??= []).Add(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,12 @@ internal sealed class HeadlessContentDescriptor
|
|||
|
||||
[JsonRequired]
|
||||
public string PreparedAssetPath { get; init; } = string.Empty;
|
||||
|
||||
public string? PreparedAssetOverlayPath { get; init; }
|
||||
|
||||
public uint? PreparedAssetBaseRecipeVersion { get; init; }
|
||||
|
||||
public uint? PreparedAssetEffectiveRecipeVersion { get; init; }
|
||||
}
|
||||
|
||||
// MF-1 (Campaign OP OP7 review fix, 2026-08-11): record, not class — the
|
||||
|
|
|
|||
|
|
@ -173,6 +173,18 @@ internal static class HeadlessConfigurationLoader
|
|||
throw new HeadlessConfigurationException(
|
||||
"process.content requires non-empty datDirectory and preparedAssetPath.");
|
||||
}
|
||||
|
||||
bool hasOverlay = !string.IsNullOrWhiteSpace(
|
||||
content.PreparedAssetOverlayPath);
|
||||
bool hasBaseRecipe = content.PreparedAssetBaseRecipeVersion is > 0;
|
||||
bool hasEffectiveRecipe =
|
||||
content.PreparedAssetEffectiveRecipeVersion is > 0;
|
||||
if (hasOverlay != hasBaseRecipe || hasOverlay != hasEffectiveRecipe)
|
||||
{
|
||||
throw new HeadlessConfigurationException(
|
||||
"process.content overlay path, base recipe, and effective recipe "
|
||||
+ "must be supplied together.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateSession(
|
||||
|
|
|
|||
|
|
@ -43,15 +43,56 @@ internal sealed class ProductionHeadlessProcessContentFactory
|
|||
string datDirectory = Path.GetFullPath(descriptor.DatDirectory);
|
||||
string preparedAssetPath =
|
||||
Path.GetFullPath(descriptor.PreparedAssetPath);
|
||||
string? overlayPath = string.IsNullOrWhiteSpace(
|
||||
descriptor.PreparedAssetOverlayPath)
|
||||
? null
|
||||
: Path.GetFullPath(descriptor.PreparedAssetOverlayPath);
|
||||
IDatReaderWriter? dats = null;
|
||||
IPreparedAssetSource? prepared = null;
|
||||
try
|
||||
{
|
||||
dats = RuntimeDatCollectionFactory.OpenReadOnly(datDirectory);
|
||||
prepared = new PakPreparedAssetSource(
|
||||
preparedAssetPath,
|
||||
dats,
|
||||
diagnostic);
|
||||
if (overlayPath is null)
|
||||
{
|
||||
prepared = new PakPreparedAssetSource(
|
||||
preparedAssetPath,
|
||||
dats,
|
||||
diagnostic);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (descriptor.PreparedAssetBaseRecipeVersion is not > 0
|
||||
|| descriptor.PreparedAssetEffectiveRecipeVersion
|
||||
!= AcDream.Content.Pak.PakFormat.CurrentBakeToolVersion)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"Layered prepared content does not match the client's recipe.");
|
||||
}
|
||||
|
||||
var baseSource = new PakPreparedAssetSource(
|
||||
preparedAssetPath,
|
||||
PreparedAssetCatalogIdentity.From(
|
||||
dats,
|
||||
descriptor.PreparedAssetBaseRecipeVersion.Value),
|
||||
diagnostic);
|
||||
try
|
||||
{
|
||||
var overlaySource = new PakPreparedAssetSource(
|
||||
overlayPath,
|
||||
PreparedAssetCatalogIdentity.From(
|
||||
dats,
|
||||
descriptor.PreparedAssetEffectiveRecipeVersion.Value),
|
||||
diagnostic);
|
||||
prepared = new LayeredPreparedAssetSource(
|
||||
baseSource,
|
||||
overlaySource);
|
||||
}
|
||||
catch
|
||||
{
|
||||
baseSource.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
MagicCatalog magic = MagicCatalog.Load(dats);
|
||||
Region region = dats.Get<Region>(0x13000000u)
|
||||
?? throw new InvalidOperationException(
|
||||
|
|
|
|||
|
|
@ -11,18 +11,43 @@ public sealed record BakeProcessRequest(
|
|||
string DatDirectory,
|
||||
string OutputPath,
|
||||
int Threads,
|
||||
string? PublicationNonce = null)
|
||||
string? PublicationNonce = null,
|
||||
IReadOnlyList<uint>? DatIds = null,
|
||||
IReadOnlyList<byte>? Landblocks = null)
|
||||
{
|
||||
public IReadOnlyList<string> Arguments =>
|
||||
[
|
||||
"--dat-dir",
|
||||
DatDirectory,
|
||||
"--out",
|
||||
OutputPath,
|
||||
"--threads",
|
||||
Threads.ToString(CultureInfo.InvariantCulture),
|
||||
"--progress-json",
|
||||
];
|
||||
public IReadOnlyList<string> Arguments
|
||||
{
|
||||
get
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"--dat-dir",
|
||||
DatDirectory,
|
||||
"--out",
|
||||
OutputPath,
|
||||
"--threads",
|
||||
Threads.ToString(CultureInfo.InvariantCulture),
|
||||
"--progress-json",
|
||||
};
|
||||
if (DatIds is { Count: > 0 })
|
||||
{
|
||||
arguments.Add("--ids");
|
||||
arguments.Add(string.Join(
|
||||
',',
|
||||
DatIds.Select(static id => $"0x{id:X8}")));
|
||||
}
|
||||
|
||||
if (Landblocks is { Count: > 0 })
|
||||
{
|
||||
arguments.Add("--landblocks");
|
||||
arguments.Add(string.Join(
|
||||
',',
|
||||
Landblocks.Select(static id => $"0x{id:X2}")));
|
||||
}
|
||||
|
||||
return arguments;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record BakeProcessResult(int ExitCode, string StandardError);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,127 @@
|
|||
namespace AcDream.Launcher.Core.Installation;
|
||||
|
||||
/// <summary>The only four prepared-content actions exposed to the launcher UI.</summary>
|
||||
public enum ContentWorkKind
|
||||
{
|
||||
None,
|
||||
Overlay,
|
||||
FullRebuild,
|
||||
Verify,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One resolved recipe migration. Overlay ids are acdream-bake's existing
|
||||
/// hexadecimal DAT-id filters; landblocks use its existing 8-bit hexadecimal
|
||||
/// landblock filter. A plan is deliberately data-only so update orchestration
|
||||
/// and the UI do not need to understand extraction algorithms.
|
||||
/// </summary>
|
||||
public sealed record ContentMigrationPlan(
|
||||
uint FromRecipeVersion,
|
||||
uint TargetRecipeVersion,
|
||||
ContentWorkKind Kind,
|
||||
string Reason,
|
||||
IReadOnlyList<uint>? DatIds = null,
|
||||
IReadOnlyList<byte>? Landblocks = null)
|
||||
{
|
||||
public IReadOnlyList<uint> EffectiveDatIds => DatIds ?? [];
|
||||
|
||||
public IReadOnlyList<byte> EffectiveLandblocks => Landblocks ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compiled content-recipe ledger. Launcher and client ship together, so the
|
||||
/// updated launcher always knows how to prepare the matching client's recipe
|
||||
/// without changing the strict release-feed schema.
|
||||
/// </summary>
|
||||
public static class ContentMigrationCatalog
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<uint, ContentMigrationPlan> Steps =
|
||||
new Dictionary<uint, ContentMigrationPlan>
|
||||
{
|
||||
[2] = FullRebuild(1, 2, "prepared EnvCell identity changed"),
|
||||
[3] = FullRebuild(2, 3, "render-pass translucency moved into prepared meshes"),
|
||||
[4] = FullRebuild(3, 4, "flat collision and EnvCell topology were added"),
|
||||
[5] = FullRebuild(
|
||||
4,
|
||||
5,
|
||||
"solid-colour positive mesh faces must be regenerated"),
|
||||
};
|
||||
|
||||
public static ContentMigrationPlan Resolve(uint fromRecipeVersion, uint targetRecipeVersion)
|
||||
{
|
||||
if (fromRecipeVersion == 0 || targetRecipeVersion == 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(fromRecipeVersion),
|
||||
"Content recipe versions must be positive.");
|
||||
}
|
||||
|
||||
if (fromRecipeVersion == targetRecipeVersion)
|
||||
{
|
||||
return new ContentMigrationPlan(
|
||||
fromRecipeVersion,
|
||||
targetRecipeVersion,
|
||||
ContentWorkKind.None,
|
||||
"Prepared content already matches this client.");
|
||||
}
|
||||
|
||||
if (fromRecipeVersion > targetRecipeVersion)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Prepared content recipe {fromRecipeVersion} is newer than this "
|
||||
+ $"launcher's recipe {targetRecipeVersion}.");
|
||||
}
|
||||
|
||||
var ids = new HashSet<uint>();
|
||||
var landblocks = new HashSet<byte>();
|
||||
var reasons = new List<string>();
|
||||
ContentWorkKind combinedKind = ContentWorkKind.None;
|
||||
for (uint target = checked(fromRecipeVersion + 1);
|
||||
target <= targetRecipeVersion;
|
||||
target++)
|
||||
{
|
||||
if (!Steps.TryGetValue(target, out ContentMigrationPlan? step)
|
||||
|| step.FromRecipeVersion != target - 1)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"No prepared-content migration is published for recipe "
|
||||
+ $"{target - 1} to {target}.");
|
||||
}
|
||||
|
||||
reasons.Add(step.Reason);
|
||||
if (step.Kind == ContentWorkKind.FullRebuild)
|
||||
{
|
||||
combinedKind = ContentWorkKind.FullRebuild;
|
||||
}
|
||||
else if (combinedKind != ContentWorkKind.FullRebuild
|
||||
&& step.Kind == ContentWorkKind.Overlay)
|
||||
{
|
||||
combinedKind = ContentWorkKind.Overlay;
|
||||
}
|
||||
|
||||
foreach (uint id in step.EffectiveDatIds)
|
||||
{
|
||||
ids.Add(id);
|
||||
}
|
||||
|
||||
foreach (byte landblock in step.EffectiveLandblocks)
|
||||
{
|
||||
landblocks.Add(landblock);
|
||||
}
|
||||
}
|
||||
|
||||
return new ContentMigrationPlan(
|
||||
fromRecipeVersion,
|
||||
targetRecipeVersion,
|
||||
combinedKind,
|
||||
string.Join("; ", reasons),
|
||||
ids.Order().ToArray(),
|
||||
landblocks.Order().ToArray());
|
||||
}
|
||||
|
||||
private static ContentMigrationPlan FullRebuild(
|
||||
uint from,
|
||||
uint target,
|
||||
string reason) =>
|
||||
new(from, target, ContentWorkKind.FullRebuild, reason);
|
||||
}
|
||||
|
|
@ -0,0 +1,413 @@
|
|||
using System.Buffers.Binary;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using AcDream.Launcher.Core.Integrity;
|
||||
using AcDream.Launcher.Core.Launching;
|
||||
using AcDream.Platform;
|
||||
|
||||
namespace AcDream.Launcher.Core.Installation;
|
||||
|
||||
public sealed record LauncherContentOverlay(
|
||||
string Path,
|
||||
string Sha256,
|
||||
long Size,
|
||||
uint RecipeVersion);
|
||||
|
||||
public sealed record LauncherContentState(
|
||||
int SchemaVersion,
|
||||
string BaseSha256,
|
||||
uint EffectiveRecipeVersion,
|
||||
LauncherContentOverlay? Overlay)
|
||||
{
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional overlay authority kept beside, rather than inside, install.json.
|
||||
/// Old launchers safely ignore this file instead of rejecting a new field in
|
||||
/// their strict install-record schema.
|
||||
/// </summary>
|
||||
public sealed class LauncherContentStateStore
|
||||
{
|
||||
private const uint PakMagic = 0x4B504341u;
|
||||
private const uint PakFormatVersion = 1;
|
||||
private const int PakHeaderSize = 64;
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
|
||||
};
|
||||
|
||||
private readonly string _pakDirectory;
|
||||
private readonly Func<string, CancellationToken, Task<string>> _computeSha256;
|
||||
|
||||
public LauncherContentStateStore(
|
||||
ApplicationPathSet paths,
|
||||
Func<string, CancellationToken, Task<string>>? computeSha256 = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
_pakDirectory = Path.Combine(
|
||||
Path.GetFullPath(paths.DataDirectory),
|
||||
"pak");
|
||||
_computeSha256 = computeSha256
|
||||
?? ((path, cancellationToken) =>
|
||||
FileIntegrity.ComputeSha256HexAsync(path, cancellationToken));
|
||||
}
|
||||
|
||||
public string StatePath => Path.Combine(_pakDirectory, "content.current.json");
|
||||
|
||||
public string ClientCompatibilityPendingPath => Path.Combine(
|
||||
_pakDirectory,
|
||||
"content.client-pending");
|
||||
|
||||
public string OverlayCandidatePath => Path.Combine(
|
||||
_pakDirectory,
|
||||
".acdream-update.candidate.pak");
|
||||
|
||||
public bool IsClientCompatibilityPending =>
|
||||
File.Exists(ClientCompatibilityPendingPath);
|
||||
|
||||
public void MarkClientCompatibilityPending()
|
||||
{
|
||||
Directory.CreateDirectory(_pakDirectory);
|
||||
string temporaryPath = ClientCompatibilityPendingPath + ".tmp";
|
||||
File.WriteAllText(
|
||||
temporaryPath,
|
||||
LauncherInstallRecordStore.CurrentBakeToolVersion.ToString(
|
||||
CultureInfo.InvariantCulture));
|
||||
File.Move(
|
||||
temporaryPath,
|
||||
ClientCompatibilityPendingPath,
|
||||
overwrite: true);
|
||||
}
|
||||
|
||||
public void ClearClientCompatibilityPending()
|
||||
{
|
||||
LauncherInstallRecordStore.TryDelete(ClientCompatibilityPendingPath);
|
||||
LauncherInstallRecordStore.TryDelete(
|
||||
ClientCompatibilityPendingPath + ".tmp");
|
||||
}
|
||||
|
||||
public string GetOverlayPath(LauncherContentOverlay overlay)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(overlay);
|
||||
string? error = ValidateOverlayFileName(overlay.Path);
|
||||
if (error is not null)
|
||||
{
|
||||
throw new InvalidDataException(error);
|
||||
}
|
||||
|
||||
return Path.Combine(_pakDirectory, overlay.Path);
|
||||
}
|
||||
|
||||
public async Task<(LauncherContentState? State, string? Error)> LoadAsync(
|
||||
LauncherInstallRecord baseRecord,
|
||||
bool forceFullVerification = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(baseRecord);
|
||||
if (!File.Exists(StatePath))
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
LauncherContentState? state;
|
||||
try
|
||||
{
|
||||
await using FileStream stream = new(
|
||||
StatePath,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
4096,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
state = await JsonSerializer.DeserializeAsync<LauncherContentState>(
|
||||
stream,
|
||||
SerializerOptions,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException
|
||||
or UnauthorizedAccessException
|
||||
or JsonException
|
||||
or NotSupportedException)
|
||||
{
|
||||
return (null, $"The prepared-content update record could not be read: {ex.Message}");
|
||||
}
|
||||
|
||||
string? contractError = ValidateContract(baseRecord, state);
|
||||
if (contractError is not null)
|
||||
{
|
||||
return (null, contractError);
|
||||
}
|
||||
|
||||
LauncherContentOverlay overlay = state!.Overlay!;
|
||||
string overlayPath = GetOverlayPath(overlay);
|
||||
if (!File.Exists(baseRecord.PreparedAssetPath))
|
||||
{
|
||||
return (null, "The base prepared package is missing.");
|
||||
}
|
||||
|
||||
if (new FileInfo(baseRecord.PreparedAssetPath).Length
|
||||
!= baseRecord.PreparedAssetSize)
|
||||
{
|
||||
return (null, "The base prepared package size changed.");
|
||||
}
|
||||
|
||||
if (!File.Exists(overlayPath))
|
||||
{
|
||||
return (null, "The prepared-content overlay is missing.");
|
||||
}
|
||||
|
||||
if (new FileInfo(overlayPath).Length != overlay.Size)
|
||||
{
|
||||
return (null, "The prepared-content overlay size changed.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
PakIdentity baseIdentity = ReadPakIdentity(baseRecord.PreparedAssetPath);
|
||||
PakIdentity overlayIdentity = ReadPakIdentity(overlayPath);
|
||||
if (baseIdentity.FormatVersion != PakFormatVersion
|
||||
|| overlayIdentity.FormatVersion != PakFormatVersion)
|
||||
{
|
||||
return (null, "The base or overlay pak format is not supported.");
|
||||
}
|
||||
|
||||
if (baseIdentity.RecipeVersion != baseRecord.BakeToolVersion
|
||||
|| overlayIdentity.RecipeVersion != overlay.RecipeVersion)
|
||||
{
|
||||
return (null, "The base or overlay content recipe does not match its record.");
|
||||
}
|
||||
|
||||
if (!baseIdentity.SameDatSet(overlayIdentity))
|
||||
{
|
||||
return (null, "The prepared-content overlay was built from a different DAT set.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException
|
||||
or UnauthorizedAccessException
|
||||
or InvalidDataException)
|
||||
{
|
||||
return (null, $"The prepared-content package header is invalid: {ex.Message}");
|
||||
}
|
||||
|
||||
if (forceFullVerification)
|
||||
{
|
||||
string baseSha = await _computeSha256(
|
||||
baseRecord.PreparedAssetPath,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!FileIntegrity.Matches(baseSha, baseRecord.PreparedAssetSha256))
|
||||
{
|
||||
return (null, "The base prepared package SHA-256 does not match its record.");
|
||||
}
|
||||
|
||||
string overlaySha = await _computeSha256(overlayPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!FileIntegrity.Matches(overlaySha, overlay.Sha256))
|
||||
{
|
||||
return (null, "The prepared-content overlay SHA-256 does not match its record.");
|
||||
}
|
||||
}
|
||||
|
||||
return (state, null);
|
||||
}
|
||||
|
||||
public async Task SaveAtomicallyAsync(
|
||||
LauncherInstallRecord baseRecord,
|
||||
LauncherContentState state,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(baseRecord);
|
||||
ArgumentNullException.ThrowIfNull(state);
|
||||
string? error = ValidateContract(baseRecord, state);
|
||||
if (error is not null)
|
||||
{
|
||||
throw new InvalidDataException(error);
|
||||
}
|
||||
|
||||
string overlayPath = GetOverlayPath(state.Overlay!);
|
||||
string? candidateError = ValidateCandidate(
|
||||
baseRecord,
|
||||
overlayPath,
|
||||
state.Overlay!);
|
||||
if (candidateError is not null)
|
||||
{
|
||||
throw new InvalidDataException(candidateError);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(_pakDirectory);
|
||||
string temporaryPath = StatePath + $".{Guid.NewGuid():N}.tmp";
|
||||
try
|
||||
{
|
||||
await using (FileStream stream = new(
|
||||
temporaryPath,
|
||||
FileMode.CreateNew,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
4096,
|
||||
FileOptions.Asynchronous | FileOptions.WriteThrough))
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(
|
||||
stream,
|
||||
state,
|
||||
SerializerOptions,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
File.Move(temporaryPath, StatePath, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
LauncherInstallRecordStore.TryDelete(temporaryPath);
|
||||
}
|
||||
}
|
||||
|
||||
public void Delete() => LauncherInstallRecordStore.TryDelete(StatePath);
|
||||
|
||||
public string? ValidateCandidate(
|
||||
LauncherInstallRecord baseRecord,
|
||||
string overlayPath,
|
||||
LauncherContentOverlay overlay)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(baseRecord);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(overlayPath);
|
||||
ArgumentNullException.ThrowIfNull(overlay);
|
||||
if (!File.Exists(baseRecord.PreparedAssetPath))
|
||||
{
|
||||
return "The base prepared package is missing.";
|
||||
}
|
||||
|
||||
if (!File.Exists(overlayPath)
|
||||
|| new FileInfo(overlayPath).Length != overlay.Size)
|
||||
{
|
||||
return "The prepared-content overlay candidate size changed.";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
PakIdentity baseIdentity = ReadPakIdentity(baseRecord.PreparedAssetPath);
|
||||
PakIdentity overlayIdentity = ReadPakIdentity(overlayPath);
|
||||
if (baseIdentity.FormatVersion != PakFormatVersion
|
||||
|| overlayIdentity.FormatVersion != PakFormatVersion
|
||||
|| baseIdentity.RecipeVersion != baseRecord.BakeToolVersion
|
||||
|| overlayIdentity.RecipeVersion != overlay.RecipeVersion
|
||||
|| !baseIdentity.SameDatSet(overlayIdentity))
|
||||
{
|
||||
return "The prepared-content overlay candidate header does not "
|
||||
+ "match the base pak and requested recipe.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException
|
||||
or UnauthorizedAccessException
|
||||
or InvalidDataException)
|
||||
{
|
||||
return $"The prepared-content overlay candidate is invalid: {ex.Message}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ValidateContract(
|
||||
LauncherInstallRecord baseRecord,
|
||||
LauncherContentState? state)
|
||||
{
|
||||
if (state is null)
|
||||
{
|
||||
return "The prepared-content update record is empty.";
|
||||
}
|
||||
|
||||
if (state.SchemaVersion != LauncherContentState.CurrentSchemaVersion)
|
||||
{
|
||||
return $"Prepared-content record version {state.SchemaVersion} is not supported.";
|
||||
}
|
||||
|
||||
if (!IsSha256(state.BaseSha256)
|
||||
|| !FileIntegrity.Matches(state.BaseSha256, baseRecord.PreparedAssetSha256))
|
||||
{
|
||||
return "The prepared-content update record does not match the installed base pak.";
|
||||
}
|
||||
|
||||
if (state.Overlay is null)
|
||||
{
|
||||
return "The prepared-content update record is missing its overlay.";
|
||||
}
|
||||
|
||||
if (state.EffectiveRecipeVersion != state.Overlay.RecipeVersion
|
||||
|| state.EffectiveRecipeVersion <= baseRecord.BakeToolVersion)
|
||||
{
|
||||
return "The prepared-content overlay recipe is not a newer effective recipe.";
|
||||
}
|
||||
|
||||
if (!IsSha256(state.Overlay.Sha256) || state.Overlay.Size <= 0)
|
||||
{
|
||||
return "The prepared-content overlay is missing valid integrity metadata.";
|
||||
}
|
||||
|
||||
return ValidateOverlayFileName(state.Overlay.Path);
|
||||
}
|
||||
|
||||
private static string? ValidateOverlayFileName(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)
|
||||
|| Path.IsPathFullyQualified(path)
|
||||
|| !string.Equals(path, Path.GetFileName(path), StringComparison.Ordinal)
|
||||
|| path is "." or ".."
|
||||
|| !path.EndsWith(".pak", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "The prepared-content overlay path must be one pak filename beneath the pak directory.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static PakIdentity ReadPakIdentity(string path)
|
||||
{
|
||||
Span<byte> header = stackalloc byte[PakHeaderSize];
|
||||
using FileStream stream = new(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
stream.ReadExactly(header);
|
||||
uint magic = BinaryPrimitives.ReadUInt32LittleEndian(header[0..4]);
|
||||
if (magic != PakMagic)
|
||||
{
|
||||
throw new InvalidDataException("pak magic does not match ACPK");
|
||||
}
|
||||
|
||||
return new PakIdentity(
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(header[4..8]),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(header[8..12]),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(header[12..16]),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(header[16..20]),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(header[20..24]),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(header[36..40]));
|
||||
}
|
||||
|
||||
private static bool IsSha256(string value) =>
|
||||
value.Length == 64 && value.All(Uri.IsHexDigit);
|
||||
|
||||
private readonly record struct PakIdentity(
|
||||
uint FormatVersion,
|
||||
uint PortalIteration,
|
||||
uint CellIteration,
|
||||
uint HighResIteration,
|
||||
uint LanguageIteration,
|
||||
uint RecipeVersion)
|
||||
{
|
||||
public bool SameDatSet(PakIdentity other) =>
|
||||
PortalIteration == other.PortalIteration
|
||||
&& CellIteration == other.CellIteration
|
||||
&& HighResIteration == other.HighResIteration
|
||||
&& LanguageIteration == other.LanguageIteration;
|
||||
}
|
||||
}
|
||||
|
|
@ -10,15 +10,20 @@ public enum InstallRecordVerificationState
|
|||
{
|
||||
Missing,
|
||||
Verified,
|
||||
ContentUpdateRequired,
|
||||
Invalid,
|
||||
}
|
||||
|
||||
public sealed record InstallRecordVerification(
|
||||
InstallRecordVerificationState State,
|
||||
LauncherInstallRecord? Record,
|
||||
string Status)
|
||||
string Status,
|
||||
ContentMigrationPlan? RequiredContentWork = null)
|
||||
{
|
||||
public bool IsVerified => State == InstallRecordVerificationState.Verified;
|
||||
|
||||
public bool RequiresContentUpdate =>
|
||||
State == InstallRecordVerificationState.ContentUpdateRequired;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -77,7 +82,8 @@ public sealed class LauncherInstallRecordStore
|
|||
/// passes false.</param>
|
||||
public async Task<InstallRecordVerification> LoadAndVerifyAsync(
|
||||
CancellationToken cancellationToken = default,
|
||||
bool forceFullVerification = false)
|
||||
bool forceFullVerification = false,
|
||||
IProgress<string>? progress = null)
|
||||
{
|
||||
await using InstallerTransactionLease lease =
|
||||
await InstallerTransactionLease.AcquireAsync(
|
||||
|
|
@ -86,13 +92,15 @@ public sealed class LauncherInstallRecordStore
|
|||
.ConfigureAwait(false);
|
||||
return await LoadAndVerifyUnderLeaseAsync(
|
||||
cancellationToken,
|
||||
forceFullVerification)
|
||||
forceFullVerification,
|
||||
progress)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
internal async Task<InstallRecordVerification> LoadAndVerifyUnderLeaseAsync(
|
||||
CancellationToken cancellationToken = default,
|
||||
bool forceFullVerification = false)
|
||||
bool forceFullVerification = false,
|
||||
IProgress<string>? progress = null)
|
||||
{
|
||||
if (!File.Exists(RecordPath))
|
||||
{
|
||||
|
|
@ -146,18 +154,59 @@ public sealed class LauncherInstallRecordStore
|
|||
|
||||
string? contractError = ValidateRecordContract(
|
||||
record,
|
||||
requireCanonicalSerializedPaths: true);
|
||||
requireCanonicalSerializedPaths: true,
|
||||
requireCurrentRecipe: false);
|
||||
if (contractError is not null)
|
||||
{
|
||||
return Invalid(contractError);
|
||||
}
|
||||
|
||||
if (record.BakeToolVersion > CurrentBakeToolVersion)
|
||||
{
|
||||
return Invalid(
|
||||
$"Prepared content recipe {record.BakeToolVersion} is newer than "
|
||||
+ $"this launcher's recipe {CurrentBakeToolVersion}. Update the launcher.");
|
||||
}
|
||||
|
||||
if (record.BakeToolVersion < CurrentBakeToolVersion)
|
||||
{
|
||||
if (!File.Exists(record.PreparedAssetPath))
|
||||
{
|
||||
return Invalid("The prepared package is missing.");
|
||||
}
|
||||
|
||||
if (new FileInfo(record.PreparedAssetPath).Length
|
||||
!= record.PreparedAssetSize)
|
||||
{
|
||||
return Invalid("The prepared package size changed.");
|
||||
}
|
||||
|
||||
ContentMigrationPlan plan;
|
||||
try
|
||||
{
|
||||
plan = ContentMigrationCatalog.Resolve(
|
||||
record.BakeToolVersion,
|
||||
CurrentBakeToolVersion);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Invalid(ex.Message);
|
||||
}
|
||||
|
||||
return new InstallRecordVerification(
|
||||
InstallRecordVerificationState.ContentUpdateRequired,
|
||||
record,
|
||||
$"World data update required: {plan.Reason}.",
|
||||
plan);
|
||||
}
|
||||
|
||||
string backupPath = GetBackupPath(record.PreparedAssetPath);
|
||||
FileVerification current = await VerifyFileAsync(
|
||||
record.PreparedAssetPath,
|
||||
record,
|
||||
cancellationToken,
|
||||
allowCachedResult: !forceFullVerification)
|
||||
allowCachedResult: !forceFullVerification,
|
||||
progress)
|
||||
.ConfigureAwait(false);
|
||||
if (current.IsValid)
|
||||
{
|
||||
|
|
@ -176,7 +225,8 @@ public sealed class LauncherInstallRecordStore
|
|||
backupPath,
|
||||
record,
|
||||
cancellationToken,
|
||||
allowCachedResult: false)
|
||||
allowCachedResult: false,
|
||||
progress)
|
||||
.ConfigureAwait(false);
|
||||
if (backup.IsValid)
|
||||
{
|
||||
|
|
@ -220,7 +270,8 @@ public sealed class LauncherInstallRecordStore
|
|||
LauncherInstallRecord normalized = NormalizeForSave(record);
|
||||
string? contractError = ValidateRecordContract(
|
||||
normalized,
|
||||
requireCanonicalSerializedPaths: true);
|
||||
requireCanonicalSerializedPaths: true,
|
||||
requireCurrentRecipe: true);
|
||||
if (contractError is not null)
|
||||
{
|
||||
throw new InvalidDataException(contractError);
|
||||
|
|
@ -263,9 +314,37 @@ public sealed class LauncherInstallRecordStore
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>Records the hash the installer just computed so the first
|
||||
/// launch after a successful bake does not immediately hash the same
|
||||
/// multi-gigabyte file again.</summary>
|
||||
internal void RememberVerifiedPackage(LauncherInstallRecord record)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
try
|
||||
{
|
||||
var file = new FileInfo(record.PreparedAssetPath);
|
||||
if (file.Exists && file.Length == record.PreparedAssetSize)
|
||||
{
|
||||
_verificationCache.Write(
|
||||
record.PreparedAssetPath,
|
||||
file.Length,
|
||||
file.LastWriteTimeUtc,
|
||||
record.PreparedAssetSha256);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException
|
||||
or UnauthorizedAccessException
|
||||
or ArgumentException
|
||||
or NotSupportedException)
|
||||
{
|
||||
// The cache is only an optimization. Startup will hash visibly.
|
||||
}
|
||||
}
|
||||
|
||||
private string? ValidateRecordContract(
|
||||
LauncherInstallRecord record,
|
||||
bool requireCanonicalSerializedPaths)
|
||||
bool requireCanonicalSerializedPaths,
|
||||
bool requireCurrentRecipe)
|
||||
{
|
||||
if (record.Version != LauncherInstallRecord.CurrentRecordVersion)
|
||||
{
|
||||
|
|
@ -277,7 +356,8 @@ public sealed class LauncherInstallRecordStore
|
|||
return "The install record is missing SHA-256, size, or bake-tool metadata.";
|
||||
}
|
||||
|
||||
if (record.BakeToolVersion != CurrentBakeToolVersion)
|
||||
if (requireCurrentRecipe
|
||||
&& record.BakeToolVersion != CurrentBakeToolVersion)
|
||||
{
|
||||
return $"Bake tool version {record.BakeToolVersion} is not supported; "
|
||||
+ $"version {CurrentBakeToolVersion} is required.";
|
||||
|
|
@ -422,7 +502,8 @@ public sealed class LauncherInstallRecordStore
|
|||
string path,
|
||||
LauncherInstallRecord record,
|
||||
CancellationToken cancellationToken,
|
||||
bool allowCachedResult)
|
||||
bool allowCachedResult,
|
||||
IProgress<string>? progress)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
|
|
@ -453,6 +534,12 @@ public sealed class LauncherInstallRecordStore
|
|||
return new FileVerification(true, "Client content verified.");
|
||||
}
|
||||
|
||||
progress?.Report(
|
||||
allowCachedResult
|
||||
? "The verification cache is missing or changed. Reading the "
|
||||
+ "whole world-data pak once; this can take around 30 seconds."
|
||||
: "Reading the whole world-data pak for explicit verification; "
|
||||
+ "this can take around 30 seconds.");
|
||||
string sha256 = await _computeSha256(path, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!FileIntegrity.Matches(sha256, record.PreparedAssetSha256))
|
||||
|
|
|
|||
|
|
@ -61,25 +61,55 @@ public interface ILauncherInstaller
|
|||
CancellationToken cancellationToken = default,
|
||||
bool forceFullVerification = false);
|
||||
|
||||
Task<InstallRecordVerification> LoadExistingWithProgressAsync(
|
||||
CancellationToken cancellationToken = default,
|
||||
bool forceFullVerification = false,
|
||||
IProgress<string>? progress = null) =>
|
||||
LoadExistingAsync(cancellationToken, forceFullVerification);
|
||||
|
||||
Task<LauncherInstallResult> InstallAsync(
|
||||
string datDirectory,
|
||||
int threads,
|
||||
IProgress<LauncherInstallProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LauncherInstallResult> ApplyContentUpdateAsync(
|
||||
string datDirectory,
|
||||
int threads,
|
||||
ContentMigrationPlan migration,
|
||||
IProgress<LauncherInstallProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
migration.Kind == ContentWorkKind.FullRebuild
|
||||
? InstallAsync(
|
||||
datDirectory,
|
||||
threads,
|
||||
progress,
|
||||
cancellationToken)
|
||||
: Task.FromException<LauncherInstallResult>(
|
||||
new NotSupportedException(
|
||||
"This installer does not support filtered content overlays."));
|
||||
|
||||
/// <summary>Clears the crash-safe gate left by a completed content
|
||||
/// migration after the active client has been confirmed compatible.</summary>
|
||||
void ConfirmClientCompatibility()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// BCL-only first-run transaction. It invokes the GL-free bake executable as
|
||||
/// a child, consumes only its versioned JSONL records, verifies the published
|
||||
/// pak, and atomically records the install. A prior verified package is moved
|
||||
/// to an adjacent recovery slot and restored on every failure/cancellation
|
||||
/// path, so a fake or crashed child cannot replace it with partial output.
|
||||
/// pak, and atomically records the install. Long full rebuilds and filtered
|
||||
/// overlays are written beside active content; the old package is touched only
|
||||
/// during the final verified publication, so cancellation and child failure
|
||||
/// leave the playable bytes in place.
|
||||
/// </summary>
|
||||
public sealed class LauncherInstaller : ILauncherInstaller
|
||||
{
|
||||
private readonly string _bakeExecutablePath;
|
||||
private readonly DatDirectoryLocator _datDirectories;
|
||||
private readonly LauncherInstallRecordStore _recordStore;
|
||||
private readonly LauncherContentStateStore _contentStateStore;
|
||||
private readonly IBakeProcessRunner _processRunner;
|
||||
private readonly Func<string, CancellationToken, Task<string>> _computeSha256;
|
||||
private readonly SemaphoreSlim _installGate = new(1, 1);
|
||||
|
|
@ -96,6 +126,7 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
string bakeExecutablePath,
|
||||
DatDirectoryLocator? datDirectories = null,
|
||||
LauncherInstallRecordStore? recordStore = null,
|
||||
LauncherContentStateStore? contentStateStore = null,
|
||||
IBakeProcessRunner? processRunner = null,
|
||||
Func<string, CancellationToken, Task<string>>? computeSha256 = null)
|
||||
{
|
||||
|
|
@ -111,6 +142,8 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
paths,
|
||||
_datDirectories,
|
||||
_computeSha256);
|
||||
_contentStateStore = contentStateStore
|
||||
?? new LauncherContentStateStore(paths, _computeSha256);
|
||||
_processRunner = processRunner ?? new SystemBakeProcessRunner();
|
||||
}
|
||||
|
||||
|
|
@ -120,9 +153,21 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
public DatDirectoryValidation ValidateDatDirectory(string? directory) =>
|
||||
_datDirectories.Validate(directory);
|
||||
|
||||
public void ConfirmClientCompatibility() =>
|
||||
_contentStateStore.ClearClientCompatibilityPending();
|
||||
|
||||
public async Task<InstallRecordVerification> LoadExistingAsync(
|
||||
CancellationToken cancellationToken = default,
|
||||
bool forceFullVerification = false)
|
||||
bool forceFullVerification = false) =>
|
||||
await LoadExistingWithProgressAsync(
|
||||
cancellationToken,
|
||||
forceFullVerification)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
public async Task<InstallRecordVerification> LoadExistingWithProgressAsync(
|
||||
CancellationToken cancellationToken = default,
|
||||
bool forceFullVerification = false,
|
||||
IProgress<string>? progress = null)
|
||||
{
|
||||
await _installGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
|
|
@ -136,8 +181,28 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
InstallRecordVerification verification =
|
||||
await RecoverExistingUnderPublicationGuardAsync(
|
||||
cancellationToken,
|
||||
forceFullVerification)
|
||||
forceFullVerification,
|
||||
progress)
|
||||
.ConfigureAwait(false);
|
||||
verification = await ResolveContentStateAsync(
|
||||
verification,
|
||||
forceFullVerification,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (verification.IsVerified
|
||||
&& verification.Record is not null
|
||||
&& _contentStateStore.IsClientCompatibilityPending)
|
||||
{
|
||||
verification = verification with
|
||||
{
|
||||
Record = verification.Record with
|
||||
{
|
||||
RequiresClientCompatibilityConfirmation = true,
|
||||
},
|
||||
Status = "World data is verified; matching client confirmation is pending.",
|
||||
};
|
||||
}
|
||||
|
||||
_verifiedRecord = verification.Record;
|
||||
return verification;
|
||||
}
|
||||
|
|
@ -210,6 +275,7 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
}
|
||||
|
||||
string outputPath = _recordStore.PreparedAssetPath;
|
||||
string bakeOutputPath = GetFullRebuildCandidatePath(outputPath);
|
||||
string backupPath = LauncherInstallRecordStore.GetBackupPath(outputPath);
|
||||
InstallRecordVerification existing =
|
||||
await RecoverExistingUnderPublicationGuardAsync(
|
||||
|
|
@ -221,6 +287,15 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
forceFullVerification: true)
|
||||
.ConfigureAwait(false);
|
||||
_verifiedRecord = existing.Record;
|
||||
LauncherContentState? priorContentState = null;
|
||||
if (existing.Record is not null)
|
||||
{
|
||||
(priorContentState, _) = await _contentStateStore.LoadAsync(
|
||||
existing.Record,
|
||||
forceFullVerification: false,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(
|
||||
Path.GetDirectoryName(outputPath)
|
||||
|
|
@ -230,12 +305,11 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
Report(
|
||||
progress,
|
||||
LauncherInstallPhase.PreparingOutput,
|
||||
"Preparing the atomic package transaction...");
|
||||
bool previousPreserved = PreservePreviousPackage(outputPath, backupPath);
|
||||
if (!previousPreserved)
|
||||
{
|
||||
LauncherInstallRecordStore.TryDelete(backupPath);
|
||||
}
|
||||
"Preparing a replacement beside the active package...");
|
||||
LauncherInstallRecordStore.TryDelete(bakeOutputPath);
|
||||
LauncherInstallRecordStore.TryDelete(backupPath);
|
||||
bool previousPreserved = false;
|
||||
bool canonicalReplaced = false;
|
||||
|
||||
var parser = new BakeProgressJsonlParser();
|
||||
var protocol = new BakeProgressProtocol();
|
||||
|
|
@ -289,12 +363,12 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
await using (
|
||||
BakePublicationGuardContract.PublicationLease publication =
|
||||
await BakePublicationGuardContract.AcquireAsync(
|
||||
outputPath,
|
||||
bakeOutputPath,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
BakePublicationGuardContract.Authorize(
|
||||
outputPath,
|
||||
bakeOutputPath,
|
||||
publicationNonce,
|
||||
publication);
|
||||
}
|
||||
|
|
@ -302,7 +376,7 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
var request = new BakeProcessRequest(
|
||||
_bakeExecutablePath,
|
||||
validation.Directory,
|
||||
outputPath,
|
||||
bakeOutputPath,
|
||||
threads,
|
||||
publicationNonce);
|
||||
BakeProcessResult processResult = await _processRunner.RunAsync(
|
||||
|
|
@ -368,13 +442,13 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
$"The bake completed with {completed.Failures:N0} failed assets.");
|
||||
}
|
||||
|
||||
if (!File.Exists(outputPath))
|
||||
if (!File.Exists(bakeOutputPath))
|
||||
{
|
||||
throw new LauncherInstallException(
|
||||
"The bake tool reported success but did not publish acdream.pak.");
|
||||
}
|
||||
|
||||
long size = new FileInfo(outputPath).Length;
|
||||
long size = new FileInfo(bakeOutputPath).Length;
|
||||
if (size <= 0 || size != completed.OutputBytes)
|
||||
{
|
||||
throw new LauncherInstallException(
|
||||
|
|
@ -385,7 +459,7 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
progress,
|
||||
LauncherInstallPhase.VerifyingPackage,
|
||||
"Computing the prepared package SHA-256...");
|
||||
string sha256 = await _computeSha256(outputPath, cancellationToken)
|
||||
string sha256 = await _computeSha256(bakeOutputPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
|
|
@ -398,15 +472,31 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
Report(
|
||||
progress,
|
||||
LauncherInstallPhase.SavingRecord,
|
||||
"Saving the verified install record...");
|
||||
"Activating the verified package...");
|
||||
previousPreserved = PreservePreviousPackage(outputPath, backupPath);
|
||||
File.Move(bakeOutputPath, outputPath, overwrite: true);
|
||||
canonicalReplaced = true;
|
||||
await _recordStore.SaveAtomicallyUnderLeaseAsync(
|
||||
record,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
_recordStore.RememberVerifiedPackage(record);
|
||||
|
||||
// A complete current-recipe base supersedes every overlay. Publish
|
||||
// the base record first, then remove the optional sidecar so a
|
||||
// crash can at worst leave a sidecar whose base digest no longer
|
||||
// binds and which startup therefore rejects.
|
||||
_contentStateStore.Delete();
|
||||
if (priorContentState?.Overlay is not null)
|
||||
{
|
||||
LauncherInstallRecordStore.TryDelete(
|
||||
_contentStateStore.GetOverlayPath(
|
||||
priorContentState.Overlay));
|
||||
}
|
||||
|
||||
_verifiedRecord = record;
|
||||
await FinalizeSuccessfulPublicationAsync(
|
||||
outputPath,
|
||||
bakeOutputPath,
|
||||
backupPath,
|
||||
publicationNonce)
|
||||
.ConfigureAwait(false);
|
||||
|
|
@ -421,9 +511,11 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
catch (OperationCanceledException)
|
||||
{
|
||||
await FinalizeFailedPublicationAsync(
|
||||
bakeOutputPath,
|
||||
outputPath,
|
||||
backupPath,
|
||||
previousPreserved,
|
||||
canonicalReplaced,
|
||||
publicationNonce)
|
||||
.ConfigureAwait(false);
|
||||
Report(
|
||||
|
|
@ -435,9 +527,11 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
catch (Exception ex)
|
||||
{
|
||||
await FinalizeFailedPublicationAsync(
|
||||
bakeOutputPath,
|
||||
outputPath,
|
||||
backupPath,
|
||||
previousPreserved,
|
||||
canonicalReplaced,
|
||||
publicationNonce)
|
||||
.ConfigureAwait(false);
|
||||
Report(
|
||||
|
|
@ -456,9 +550,42 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
private async Task<InstallRecordVerification>
|
||||
RecoverExistingUnderPublicationGuardAsync(
|
||||
CancellationToken cancellationToken,
|
||||
bool forceFullVerification = false)
|
||||
bool forceFullVerification = false,
|
||||
IProgress<string>? progress = null)
|
||||
{
|
||||
string outputPath = _recordStore.PreparedAssetPath;
|
||||
string candidatePath = GetFullRebuildCandidatePath(outputPath);
|
||||
await using (
|
||||
BakePublicationGuardContract.PublicationLease candidatePublication =
|
||||
await BakePublicationGuardContract.AcquireAsync(
|
||||
candidatePath,
|
||||
cancellationToken,
|
||||
PublicationLeaseContentionObservedForTest)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
BakePublicationGuardContract.Invalidate(
|
||||
candidatePath,
|
||||
candidatePublication);
|
||||
LauncherInstallRecordStore.TryDelete(candidatePath);
|
||||
BakeOutputStagingContract.DeleteOwnedStagingFiles(candidatePath);
|
||||
}
|
||||
|
||||
string overlayCandidatePath = _contentStateStore.OverlayCandidatePath;
|
||||
await using (
|
||||
BakePublicationGuardContract.PublicationLease overlayPublication =
|
||||
await BakePublicationGuardContract.AcquireAsync(
|
||||
overlayCandidatePath,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
BakePublicationGuardContract.Invalidate(
|
||||
overlayCandidatePath,
|
||||
overlayPublication);
|
||||
LauncherInstallRecordStore.TryDelete(overlayCandidatePath);
|
||||
BakeOutputStagingContract.DeleteOwnedStagingFiles(
|
||||
overlayCandidatePath);
|
||||
}
|
||||
|
||||
await using BakePublicationGuardContract.PublicationLease publication =
|
||||
await BakePublicationGuardContract.AcquireAsync(
|
||||
outputPath,
|
||||
|
|
@ -472,10 +599,473 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath);
|
||||
return await _recordStore.LoadAndVerifyUnderLeaseAsync(
|
||||
cancellationToken,
|
||||
forceFullVerification)
|
||||
forceFullVerification,
|
||||
progress)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<LauncherInstallResult> ApplyContentUpdateAsync(
|
||||
string datDirectory,
|
||||
int threads,
|
||||
ContentMigrationPlan migration,
|
||||
IProgress<LauncherInstallProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(migration);
|
||||
if (migration.Kind is not ContentWorkKind.FullRebuild
|
||||
and not ContentWorkKind.Overlay)
|
||||
{
|
||||
throw new LauncherInstallException(
|
||||
$"Content work kind {migration.Kind} cannot build an update.");
|
||||
}
|
||||
|
||||
if (threads <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(threads),
|
||||
"Bake thread count must be positive.");
|
||||
}
|
||||
|
||||
await _installGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
bool compatibilityMarkerPublished = false;
|
||||
try
|
||||
{
|
||||
await using InstallerTransactionLease lease =
|
||||
await InstallerTransactionLease.AcquireAsync(
|
||||
_recordStore.DataDirectory,
|
||||
cancellationToken,
|
||||
TransactionLeaseContentionObservedForTest)
|
||||
.ConfigureAwait(false);
|
||||
// Publish the tiny gate before changing any content bytes. A
|
||||
// crash can therefore leave an unnecessary confirmation prompt,
|
||||
// but can never forget a required one after the pak changes.
|
||||
_contentStateStore.MarkClientCompatibilityPending();
|
||||
compatibilityMarkerPublished = true;
|
||||
LauncherInstallResult result = migration.Kind == ContentWorkKind.FullRebuild
|
||||
? await InstallCoreAsync(
|
||||
datDirectory,
|
||||
threads,
|
||||
progress,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
: await InstallOverlayCoreAsync(
|
||||
datDirectory,
|
||||
threads,
|
||||
migration,
|
||||
progress,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
LauncherInstallRecord gatedRecord = result.Record with
|
||||
{
|
||||
RequiresClientCompatibilityConfirmation = true,
|
||||
};
|
||||
_verifiedRecord = gatedRecord;
|
||||
return new LauncherInstallResult(gatedRecord);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (compatibilityMarkerPublished)
|
||||
{
|
||||
_contentStateStore.ClearClientCompatibilityPending();
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_installGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LauncherInstallResult> InstallOverlayCoreAsync(
|
||||
string datDirectory,
|
||||
int threads,
|
||||
ContentMigrationPlan migration,
|
||||
IProgress<LauncherInstallProgress>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (migration.TargetRecipeVersion
|
||||
!= LauncherInstallRecordStore.CurrentBakeToolVersion)
|
||||
{
|
||||
throw new LauncherInstallException(
|
||||
$"Overlay target recipe {migration.TargetRecipeVersion} does not "
|
||||
+ $"match launcher recipe "
|
||||
+ $"{LauncherInstallRecordStore.CurrentBakeToolVersion}.");
|
||||
}
|
||||
|
||||
if (migration.EffectiveDatIds.Count == 0
|
||||
&& migration.EffectiveLandblocks.Count == 0)
|
||||
{
|
||||
throw new LauncherInstallException(
|
||||
"An overlay migration must name at least one DAT id or landblock.");
|
||||
}
|
||||
|
||||
DatDirectoryValidation validation = _datDirectories.Validate(datDirectory);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
throw new LauncherInstallException(
|
||||
validation.Message + FormatMissing(validation.MissingFileNames));
|
||||
}
|
||||
|
||||
if (!File.Exists(_bakeExecutablePath))
|
||||
{
|
||||
throw new LauncherInstallException(
|
||||
$"The co-deployed bake tool is missing at '{_bakeExecutablePath}'.");
|
||||
}
|
||||
|
||||
InstallRecordVerification baseVerification =
|
||||
await RecoverExistingUnderPublicationGuardAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
LauncherInstallRecord baseRecord = baseVerification.Record
|
||||
?? throw new LauncherInstallException(
|
||||
"A verified base pak is required before building an overlay.");
|
||||
if (baseRecord.BakeToolVersion != migration.FromRecipeVersion)
|
||||
{
|
||||
throw new LauncherInstallException(
|
||||
$"The overlay plan starts at recipe {migration.FromRecipeVersion}, "
|
||||
+ $"but the installed base is recipe {baseRecord.BakeToolVersion}.");
|
||||
}
|
||||
|
||||
(LauncherContentState? priorState, string? priorStateError) =
|
||||
await _contentStateStore.LoadAsync(
|
||||
baseRecord,
|
||||
forceFullVerification: false,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (priorStateError is not null)
|
||||
{
|
||||
throw new LauncherInstallException(priorStateError);
|
||||
}
|
||||
|
||||
string candidatePath = _contentStateStore.OverlayCandidatePath;
|
||||
LauncherInstallRecordStore.TryDelete(candidatePath);
|
||||
string? publicationNonce = null;
|
||||
string? publishedOverlayPath = null;
|
||||
bool statePublished = false;
|
||||
var parser = new BakeProgressJsonlParser();
|
||||
var protocol = new BakeProgressProtocol();
|
||||
|
||||
void Observe(BakeProgressEvent progressEvent)
|
||||
{
|
||||
bool accepted = protocol.Observe(progressEvent);
|
||||
switch (progressEvent)
|
||||
{
|
||||
case BakeWorkProgressEvent value when accepted:
|
||||
LauncherInstallPhase phase = value.Phase == "collision"
|
||||
? LauncherInstallPhase.BakingCollision
|
||||
: LauncherInstallPhase.BakingMeshes;
|
||||
Report(
|
||||
progress,
|
||||
phase,
|
||||
$"Building world-data overlay: {value.Completed:N0}/"
|
||||
+ $"{value.Total:N0}; failures: {value.Failures:N0}",
|
||||
value.Completed,
|
||||
value.Total,
|
||||
value.Failures,
|
||||
value.EtaSeconds);
|
||||
break;
|
||||
case BakeErrorEvent value when accepted:
|
||||
Report(progress, LauncherInstallPhase.Failed, value.Message);
|
||||
break;
|
||||
case MalformedBakeProgressEvent value:
|
||||
Report(progress, LauncherInstallPhase.Failed, value.Reason);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Report(
|
||||
progress,
|
||||
LauncherInstallPhase.PreparingOutput,
|
||||
"Preparing a small filtered overlay beside active content...");
|
||||
publicationNonce = BakePublicationGuardPaths.CreateNonce();
|
||||
await using (
|
||||
BakePublicationGuardContract.PublicationLease publication =
|
||||
await BakePublicationGuardContract.AcquireAsync(
|
||||
candidatePath,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
BakePublicationGuardContract.Authorize(
|
||||
candidatePath,
|
||||
publicationNonce,
|
||||
publication);
|
||||
}
|
||||
|
||||
var request = new BakeProcessRequest(
|
||||
_bakeExecutablePath,
|
||||
validation.Directory,
|
||||
candidatePath,
|
||||
threads,
|
||||
publicationNonce,
|
||||
migration.EffectiveDatIds,
|
||||
migration.EffectiveLandblocks);
|
||||
BakeProcessResult processResult = await _processRunner.RunAsync(
|
||||
request,
|
||||
chunk =>
|
||||
{
|
||||
foreach (BakeProgressEvent value in parser.Append(chunk))
|
||||
{
|
||||
Observe(value);
|
||||
}
|
||||
},
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
foreach (BakeProgressEvent value in parser.Complete())
|
||||
{
|
||||
Observe(value);
|
||||
}
|
||||
|
||||
protocol.CompleteInput();
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (protocol.Violation is not null)
|
||||
{
|
||||
throw new LauncherInstallException(protocol.Violation);
|
||||
}
|
||||
|
||||
if (processResult.ExitCode != 0)
|
||||
{
|
||||
throw new LauncherInstallException(
|
||||
BuildChildFailure(
|
||||
processResult.ExitCode,
|
||||
protocol.Error?.Message,
|
||||
processResult.StandardError));
|
||||
}
|
||||
|
||||
if (protocol.Error is not null)
|
||||
{
|
||||
throw new LauncherInstallException(protocol.Error.Message);
|
||||
}
|
||||
|
||||
BakeStartedEvent started = protocol.Started
|
||||
?? throw new LauncherInstallException(
|
||||
"The bake protocol did not report a started event.");
|
||||
BakeCompletedEvent completed = protocol.Completed
|
||||
?? throw new LauncherInstallException(
|
||||
"The bake protocol did not report a completed event.");
|
||||
if (started.BakeToolVersion != migration.TargetRecipeVersion
|
||||
|| completed.BakeToolVersion != migration.TargetRecipeVersion
|
||||
|| completed.Failures != 0)
|
||||
{
|
||||
throw new LauncherInstallException(
|
||||
"The filtered bake did not complete with the requested recipe.");
|
||||
}
|
||||
|
||||
if (!File.Exists(candidatePath))
|
||||
{
|
||||
throw new LauncherInstallException(
|
||||
"The filtered bake did not publish an overlay candidate.");
|
||||
}
|
||||
|
||||
long size = new FileInfo(candidatePath).Length;
|
||||
if (size <= 0 || size != completed.OutputBytes)
|
||||
{
|
||||
throw new LauncherInstallException(
|
||||
"The overlay candidate size does not match bake completion.");
|
||||
}
|
||||
|
||||
Report(
|
||||
progress,
|
||||
LauncherInstallPhase.VerifyingPackage,
|
||||
"Verifying the small world-data overlay...");
|
||||
string sha256 = await _computeSha256(candidatePath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
string fileName = $"acdream-update-{migration.TargetRecipeVersion}-"
|
||||
+ $"{sha256[..12].ToLowerInvariant()}.pak";
|
||||
var overlay = new LauncherContentOverlay(
|
||||
fileName,
|
||||
sha256,
|
||||
size,
|
||||
migration.TargetRecipeVersion);
|
||||
string? candidateError = _contentStateStore.ValidateCandidate(
|
||||
baseRecord,
|
||||
candidatePath,
|
||||
overlay);
|
||||
if (candidateError is not null)
|
||||
{
|
||||
throw new LauncherInstallException(candidateError);
|
||||
}
|
||||
|
||||
publishedOverlayPath = _contentStateStore.GetOverlayPath(overlay);
|
||||
File.Move(candidatePath, publishedOverlayPath, overwrite: true);
|
||||
await FinalizeSuccessfulPublicationAsync(
|
||||
candidatePath,
|
||||
backupPath: candidatePath + ".unused",
|
||||
publicationNonce)
|
||||
.ConfigureAwait(false);
|
||||
var state = new LauncherContentState(
|
||||
LauncherContentState.CurrentSchemaVersion,
|
||||
baseRecord.PreparedAssetSha256,
|
||||
migration.TargetRecipeVersion,
|
||||
overlay);
|
||||
Report(
|
||||
progress,
|
||||
LauncherInstallPhase.SavingRecord,
|
||||
"Activating the verified world-data overlay...");
|
||||
await _contentStateStore.SaveAtomicallyAsync(
|
||||
baseRecord,
|
||||
state,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
statePublished = true;
|
||||
|
||||
if (priorState?.Overlay is not null)
|
||||
{
|
||||
string priorPath = _contentStateStore.GetOverlayPath(
|
||||
priorState.Overlay);
|
||||
if (!PathsEqual(priorPath, publishedOverlayPath))
|
||||
{
|
||||
LauncherInstallRecordStore.TryDelete(priorPath);
|
||||
}
|
||||
}
|
||||
|
||||
var resolvedRecord = baseRecord with
|
||||
{
|
||||
PreparedAssetOverlayPath = publishedOverlayPath,
|
||||
EffectiveBakeToolVersion = migration.TargetRecipeVersion,
|
||||
};
|
||||
_verifiedRecord = resolvedRecord;
|
||||
Report(
|
||||
progress,
|
||||
LauncherInstallPhase.Completed,
|
||||
"World data overlay installed and verified.",
|
||||
1,
|
||||
1);
|
||||
return new LauncherInstallResult(resolvedRecord);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
await FinalizeOverlayFailureAsync(
|
||||
candidatePath,
|
||||
publishedOverlayPath,
|
||||
statePublished,
|
||||
publicationNonce)
|
||||
.ConfigureAwait(false);
|
||||
Report(
|
||||
progress,
|
||||
LauncherInstallPhase.Cancelled,
|
||||
"World data update cancelled; active content was preserved.");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await FinalizeOverlayFailureAsync(
|
||||
candidatePath,
|
||||
publishedOverlayPath,
|
||||
statePublished,
|
||||
publicationNonce)
|
||||
.ConfigureAwait(false);
|
||||
Report(
|
||||
progress,
|
||||
LauncherInstallPhase.Failed,
|
||||
$"World data update failed: {ex.Message}");
|
||||
if (ex is LauncherInstallException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
|
||||
throw new LauncherInstallException("World data update failed.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<InstallRecordVerification> ResolveContentStateAsync(
|
||||
InstallRecordVerification baseVerification,
|
||||
bool forceFullVerification,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
LauncherInstallRecord? record = baseVerification.Record;
|
||||
if (record is null)
|
||||
{
|
||||
return baseVerification;
|
||||
}
|
||||
|
||||
if (baseVerification.IsVerified
|
||||
&& record.BakeToolVersion
|
||||
== LauncherInstallRecordStore.CurrentBakeToolVersion)
|
||||
{
|
||||
// A complete current-recipe base is sufficient by itself. A
|
||||
// sidecar from a newer launcher may remain across client rollback;
|
||||
// this client safely ignores it instead of rejecting the base.
|
||||
return baseVerification;
|
||||
}
|
||||
|
||||
(LauncherContentState? state, string? error) =
|
||||
await _contentStateStore.LoadAsync(
|
||||
record,
|
||||
forceFullVerification,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (error is not null)
|
||||
{
|
||||
if (!forceFullVerification
|
||||
&& baseVerification.RequiresContentUpdate)
|
||||
{
|
||||
return baseVerification with
|
||||
{
|
||||
Status = baseVerification.Status
|
||||
+ " The previous overlay was ignored because it is invalid.",
|
||||
};
|
||||
}
|
||||
|
||||
return new InstallRecordVerification(
|
||||
InstallRecordVerificationState.Invalid,
|
||||
null,
|
||||
error);
|
||||
}
|
||||
|
||||
if (state?.Overlay is not null)
|
||||
{
|
||||
if (state.EffectiveRecipeVersion
|
||||
> LauncherInstallRecordStore.CurrentBakeToolVersion)
|
||||
{
|
||||
return new InstallRecordVerification(
|
||||
InstallRecordVerificationState.Invalid,
|
||||
null,
|
||||
$"Prepared content recipe {state.EffectiveRecipeVersion} "
|
||||
+ $"does not match required recipe "
|
||||
+ $"{LauncherInstallRecordStore.CurrentBakeToolVersion}.");
|
||||
}
|
||||
|
||||
if (state.EffectiveRecipeVersion
|
||||
< LauncherInstallRecordStore.CurrentBakeToolVersion)
|
||||
{
|
||||
ContentMigrationPlan migration;
|
||||
try
|
||||
{
|
||||
migration = ContentMigrationCatalog.Resolve(
|
||||
record.BakeToolVersion,
|
||||
LauncherInstallRecordStore.CurrentBakeToolVersion);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return new InstallRecordVerification(
|
||||
InstallRecordVerificationState.Invalid,
|
||||
null,
|
||||
ex.Message);
|
||||
}
|
||||
|
||||
return new InstallRecordVerification(
|
||||
InstallRecordVerificationState.ContentUpdateRequired,
|
||||
record,
|
||||
$"World data update required: {migration.Reason}.",
|
||||
migration);
|
||||
}
|
||||
|
||||
return new InstallRecordVerification(
|
||||
InstallRecordVerificationState.Verified,
|
||||
record with
|
||||
{
|
||||
PreparedAssetOverlayPath =
|
||||
_contentStateStore.GetOverlayPath(state.Overlay),
|
||||
EffectiveBakeToolVersion = state.EffectiveRecipeVersion,
|
||||
},
|
||||
"Base and overlay client content verified.");
|
||||
}
|
||||
|
||||
return baseVerification;
|
||||
}
|
||||
|
||||
private static async Task FinalizeSuccessfulPublicationAsync(
|
||||
string outputPath,
|
||||
string backupPath,
|
||||
|
|
@ -495,22 +1085,58 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
}
|
||||
|
||||
private static async Task FinalizeFailedPublicationAsync(
|
||||
string publicationPath,
|
||||
string outputPath,
|
||||
string backupPath,
|
||||
bool previousPreserved,
|
||||
bool canonicalReplaced,
|
||||
string? publicationNonce)
|
||||
{
|
||||
await using BakePublicationGuardContract.PublicationLease publication =
|
||||
await BakePublicationGuardContract.AcquireAsync(
|
||||
outputPath,
|
||||
publicationPath,
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
BakePublicationGuardContract.Invalidate(
|
||||
outputPath,
|
||||
publicationPath,
|
||||
publication,
|
||||
publicationNonce);
|
||||
RestorePreviousPackage(outputPath, backupPath, previousPreserved);
|
||||
BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath);
|
||||
if (canonicalReplaced)
|
||||
{
|
||||
LauncherInstallRecordStore.TryDelete(outputPath);
|
||||
}
|
||||
|
||||
if (previousPreserved && File.Exists(backupPath))
|
||||
{
|
||||
File.Move(backupPath, outputPath, overwrite: true);
|
||||
}
|
||||
|
||||
LauncherInstallRecordStore.TryDelete(publicationPath);
|
||||
LauncherInstallRecordStore.TryDelete(backupPath);
|
||||
BakeOutputStagingContract.DeleteOwnedStagingFiles(publicationPath);
|
||||
}
|
||||
|
||||
private static async Task FinalizeOverlayFailureAsync(
|
||||
string candidatePath,
|
||||
string? publishedOverlayPath,
|
||||
bool statePublished,
|
||||
string? publicationNonce)
|
||||
{
|
||||
await using BakePublicationGuardContract.PublicationLease publication =
|
||||
await BakePublicationGuardContract.AcquireAsync(
|
||||
candidatePath,
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
BakePublicationGuardContract.Invalidate(
|
||||
candidatePath,
|
||||
publication,
|
||||
publicationNonce);
|
||||
LauncherInstallRecordStore.TryDelete(candidatePath);
|
||||
BakeOutputStagingContract.DeleteOwnedStagingFiles(candidatePath);
|
||||
if (!statePublished && publishedOverlayPath is not null)
|
||||
{
|
||||
LauncherInstallRecordStore.TryDelete(publishedOverlayPath);
|
||||
}
|
||||
}
|
||||
|
||||
private bool PreservePreviousPackage(string outputPath, string backupPath)
|
||||
|
|
@ -527,20 +1153,8 @@ public sealed class LauncherInstaller : ILauncherInstaller
|
|||
return true;
|
||||
}
|
||||
|
||||
private static void RestorePreviousPackage(
|
||||
string outputPath,
|
||||
string backupPath,
|
||||
bool previousPreserved)
|
||||
{
|
||||
if (previousPreserved && File.Exists(backupPath))
|
||||
{
|
||||
File.Move(backupPath, outputPath, overwrite: true);
|
||||
return;
|
||||
}
|
||||
|
||||
LauncherInstallRecordStore.TryDelete(outputPath);
|
||||
LauncherInstallRecordStore.TryDelete(backupPath);
|
||||
}
|
||||
internal static string GetFullRebuildCandidatePath(string outputPath) =>
|
||||
outputPath + ".candidate";
|
||||
|
||||
private static string BuildChildFailure(
|
||||
int exitCode,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AcDream.Launcher.Core.Launching;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -23,4 +25,29 @@ public sealed record LauncherInstallRecord(
|
|||
&& PreparedAssetSha256.Length == 64
|
||||
&& PreparedAssetSize > 0
|
||||
&& BakeToolVersion > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Resolved overlay metadata is runtime-only. It is explicitly excluded
|
||||
/// from install.json so old strict-schema launchers continue accepting the
|
||||
/// base record after a newer launcher publishes content.current.json.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string? PreparedAssetOverlayPath { get; init; }
|
||||
|
||||
[JsonIgnore]
|
||||
public uint EffectiveBakeToolVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Transient launcher gate restored from a tiny marker beside the pak.
|
||||
/// It is not part of strict schema-1 install.json and is never copied into
|
||||
/// a client session configuration.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool RequiresClientCompatibilityConfirmation { get; init; }
|
||||
|
||||
[JsonIgnore]
|
||||
public uint ResolvedBakeToolVersion =>
|
||||
EffectiveBakeToolVersion == 0
|
||||
? BakeToolVersion
|
||||
: EffectiveBakeToolVersion;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ public static class SessionConfigComposer
|
|||
ArgumentNullException.ThrowIfNull(install);
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
|
||||
EnsureClientCompatibilityConfirmed(install);
|
||||
|
||||
(string configFilePath, string statusFilePath, string stderrLogPath) =
|
||||
BuildSessionPaths(paths, sessionId);
|
||||
|
|
@ -154,6 +155,15 @@ public static class SessionConfigComposer
|
|||
{
|
||||
DatDirectory = install.DatDirectory,
|
||||
PreparedAssetPath = install.PreparedAssetPath,
|
||||
PreparedAssetOverlayPath = install.PreparedAssetOverlayPath,
|
||||
PreparedAssetBaseRecipeVersion =
|
||||
install.PreparedAssetOverlayPath is null
|
||||
? null
|
||||
: install.BakeToolVersion,
|
||||
PreparedAssetEffectiveRecipeVersion =
|
||||
install.PreparedAssetOverlayPath is null
|
||||
? null
|
||||
: install.ResolvedBakeToolVersion,
|
||||
},
|
||||
},
|
||||
Sessions = [descriptor],
|
||||
|
|
@ -188,6 +198,7 @@ public static class SessionConfigComposer
|
|||
ArgumentNullException.ThrowIfNull(install);
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
|
||||
EnsureClientCompatibilityConfirmed(install);
|
||||
|
||||
(string configFilePath, string statusFilePath, string stderrLogPath) =
|
||||
BuildSessionPaths(paths, sessionId);
|
||||
|
|
@ -219,6 +230,15 @@ public static class SessionConfigComposer
|
|||
{
|
||||
DatDirectory = install.DatDirectory,
|
||||
PreparedAssetPath = install.PreparedAssetPath,
|
||||
PreparedAssetOverlayPath = install.PreparedAssetOverlayPath,
|
||||
PreparedAssetBaseRecipeVersion =
|
||||
install.PreparedAssetOverlayPath is null
|
||||
? null
|
||||
: install.BakeToolVersion,
|
||||
PreparedAssetEffectiveRecipeVersion =
|
||||
install.PreparedAssetOverlayPath is null
|
||||
? null
|
||||
: install.ResolvedBakeToolVersion,
|
||||
},
|
||||
},
|
||||
Sessions = [descriptor],
|
||||
|
|
@ -304,6 +324,17 @@ public static class SessionConfigComposer
|
|||
return [.. configured];
|
||||
}
|
||||
|
||||
private static void EnsureClientCompatibilityConfirmed(
|
||||
LauncherInstallRecord install)
|
||||
{
|
||||
if (install.RequiresClientCompatibilityConfirmation)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Prepared content cannot be launched until the matching client "
|
||||
+ "has been confirmed or installed.");
|
||||
}
|
||||
}
|
||||
|
||||
private static ComposedSessionConfig Write(ComposedSessionConfig composed)
|
||||
{
|
||||
string? directory = Path.GetDirectoryName(composed.ConfigFilePath);
|
||||
|
|
|
|||
|
|
@ -70,6 +70,15 @@ public sealed class SessionContentDescriptor
|
|||
public string DatDirectory { get; init; } = string.Empty;
|
||||
|
||||
public string PreparedAssetPath { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Present only for a layered prepared-content session.</summary>
|
||||
public string? PreparedAssetOverlayPath { get; init; }
|
||||
|
||||
/// <summary>Present with an overlay so the client can validate both pak
|
||||
/// headers without assuming the base uses its current recipe.</summary>
|
||||
public uint? PreparedAssetBaseRecipeVersion { get; init; }
|
||||
|
||||
public uint? PreparedAssetEffectiveRecipeVersion { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SessionDescriptor
|
||||
|
|
|
|||
|
|
@ -27,6 +27,17 @@ public interface ILauncherOrchestrator : IDisposable
|
|||
|
||||
void SetInstallRecord(LauncherInstallRecord? installRecord);
|
||||
|
||||
/// <summary>
|
||||
/// Publishes the exact result of asynchronous launcher content discovery.
|
||||
/// The default keeps test/injected implementations source-compatible;
|
||||
/// production additionally retains <paramref name="installationStatus"/>
|
||||
/// so a failed check is not flattened into a misleading first-run message.
|
||||
/// </summary>
|
||||
void SetInstallationState(
|
||||
LauncherInstallRecord? installRecord,
|
||||
string installationStatus) =>
|
||||
SetInstallRecord(installRecord);
|
||||
|
||||
void AddServer(string name, string host, int port);
|
||||
|
||||
void EditServer(string name, string newName, string newHost, int newPort);
|
||||
|
|
|
|||
|
|
@ -242,13 +242,23 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
|||
|
||||
public void SetInstallRecord(LauncherInstallRecord? installRecord)
|
||||
{
|
||||
SetInstallationState(
|
||||
installRecord,
|
||||
installRecord is null
|
||||
? FirstRunRequired
|
||||
: "Client content SHA-256, size, and bake-tool version verified.");
|
||||
}
|
||||
|
||||
public void SetInstallationState(
|
||||
LauncherInstallRecord? installRecord,
|
||||
string installationStatus)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(installationStatus);
|
||||
lock (_gate)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
_installRecord = installRecord;
|
||||
_installationStatus = installRecord is null
|
||||
? FirstRunRequired
|
||||
: "Client content SHA-256, size, and bake-tool version verified.";
|
||||
_installationStatus = installationStatus;
|
||||
}
|
||||
|
||||
RaiseStateChanged();
|
||||
|
|
|
|||
|
|
@ -49,23 +49,6 @@ public sealed partial class App : Application
|
|||
Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"acdream-bake" + executableSuffix));
|
||||
InstallRecordVerification verification;
|
||||
try
|
||||
{
|
||||
// Hashing the package before constructing the orchestrator is
|
||||
// intentional: no launch action is enabled until the persisted
|
||||
// size/SHA/tool-version record has been verified.
|
||||
verification = installer.LoadExistingAsync()
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
verification = new InstallRecordVerification(
|
||||
InstallRecordVerificationState.Invalid,
|
||||
null,
|
||||
$"Client content verification failed: {ex.Message}");
|
||||
}
|
||||
|
||||
LauncherUpdateComposition updates = LauncherUpdateComposition.Create(
|
||||
paths,
|
||||
|
|
@ -81,8 +64,8 @@ public sealed partial class App : Application
|
|||
profiles,
|
||||
paths,
|
||||
updates.Executables,
|
||||
verification.Record,
|
||||
installationStatus: verification.Status,
|
||||
installRecord: null,
|
||||
installationStatus: "Checking installed game content…",
|
||||
updateSessionBarrier: updates.Versions.Barrier);
|
||||
// LU2: a launcher update installs and restarts by itself. The
|
||||
// helper waits on THIS process id and cannot replace files the
|
||||
|
|
@ -108,18 +91,32 @@ public sealed partial class App : Application
|
|||
updates.Updater,
|
||||
applyLauncherUpdate,
|
||||
() => desktop.Shutdown());
|
||||
_viewModel.Initialize();
|
||||
|
||||
desktop.MainWindow = new MainWindow
|
||||
var mainWindow = new MainWindow
|
||||
{
|
||||
DataContext = _viewModel,
|
||||
};
|
||||
desktop.MainWindow = mainWindow;
|
||||
_viewModel.Initialize();
|
||||
mainWindow.Opened += OnMainWindowOpened;
|
||||
desktop.Exit += OnDesktopExit;
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private void OnMainWindowOpened(object? sender, EventArgs e)
|
||||
{
|
||||
if (sender is MainWindow window)
|
||||
{
|
||||
window.Opened -= OnMainWindowOpened;
|
||||
}
|
||||
|
||||
if (_viewModel is not null)
|
||||
{
|
||||
_ = _viewModel.StartBackgroundInitializationAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDesktopExit(object? sender, ControlledApplicationLifetimeExitEventArgs e)
|
||||
{
|
||||
_viewModel?.Dispose();
|
||||
|
|
|
|||
|
|
@ -71,9 +71,15 @@ internal sealed class LauncherUpdateComposition : IDisposable
|
|||
ReleaseManifestClient? manifestClient = null;
|
||||
try
|
||||
{
|
||||
_ = initialize is null
|
||||
? versions.LoadAndRecoverAsync(rid).GetAwaiter().GetResult()
|
||||
: initialize(versions, rid);
|
||||
// Production initialization is deliberately deferred until after
|
||||
// MainWindow.Opened. Client-version recovery verifies every file
|
||||
// in the active version and is therefore not composition-root
|
||||
// work. The injectable callback remains only for focused failure
|
||||
// composition tests.
|
||||
if (initialize is not null)
|
||||
{
|
||||
_ = initialize(versions, rid);
|
||||
}
|
||||
artifactClient = new HttpClient(
|
||||
new HttpClientHandler
|
||||
{
|
||||
|
|
|
|||
|
|
@ -56,14 +56,15 @@
|
|||
<Border Classes="card"
|
||||
Padding="12"
|
||||
Background="#4B3820"
|
||||
IsVisible="{Binding IsFirstRunRequired}">
|
||||
IsVisible="{Binding ShowInstallationBanner}">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Spacing="3">
|
||||
<TextBlock Text="Client setup required" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding InstallationBannerTitle}" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding InstallationStatus}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1"
|
||||
Content="Open setup"
|
||||
IsVisible="{Binding !IsInstallationChecking}"
|
||||
Command="{Binding FirstRunWizardShell.OpenCommand}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
|
@ -472,7 +473,7 @@
|
|||
IsCancel="True"
|
||||
AutomationProperties.Name="Close first-run setup"
|
||||
Command="{Binding FirstRunWizardShell.CloseCommand}" />
|
||||
<Button Content="Build and install"
|
||||
<Button Content="{Binding FirstRunWizardShell.StartActionText}"
|
||||
Classes="primary"
|
||||
IsDefault="True"
|
||||
AutomationProperties.Name="Build and install client content"
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ public sealed class FirstRunInstallerViewModel : ObservableObject, IDisposable
|
|||
private bool _isOpen;
|
||||
private bool _isRunning;
|
||||
private bool _isDatDirectoryValid;
|
||||
private LauncherInstallRecord? _contentUpdateBase;
|
||||
private ContentMigrationPlan? _contentMigration;
|
||||
private string? _completionRequirement;
|
||||
private bool _disposed;
|
||||
|
||||
public FirstRunInstallerViewModel(
|
||||
|
|
@ -57,12 +60,24 @@ public sealed class FirstRunInstallerViewModel : ObservableObject, IDisposable
|
|||
() => IsRunning && _cancellation is not null);
|
||||
}
|
||||
|
||||
public string Title => "First-run setup";
|
||||
public bool IsContentUpdate => _contentMigration is not null;
|
||||
|
||||
public string Body =>
|
||||
"Select the retail Asheron's Call DAT folder. acdream will validate "
|
||||
+ "the four required files, build DataDirectory/pak/acdream.pak, and "
|
||||
+ "verify its SHA-256 before enabling launch.";
|
||||
public string Title => IsContentUpdate
|
||||
? "World data update required"
|
||||
: "First-run setup";
|
||||
|
||||
public string Body => IsContentUpdate
|
||||
? BuildContentUpdateBody()
|
||||
: "Select the retail Asheron's Call DAT folder. acdream will validate "
|
||||
+ "the four required files, build DataDirectory/pak/acdream.pak, and "
|
||||
+ "verify its SHA-256 before enabling launch. No work begins until "
|
||||
+ "you choose Build and install.";
|
||||
|
||||
public string StartActionText => IsContentUpdate
|
||||
? _contentMigration?.Kind == ContentWorkKind.Overlay
|
||||
? "Build small update"
|
||||
: "Rebuild world data"
|
||||
: "Build and install";
|
||||
|
||||
public string DatDirectory
|
||||
{
|
||||
|
|
@ -233,10 +248,51 @@ public sealed class FirstRunInstallerViewModel : ObservableObject, IDisposable
|
|||
/// <summary>The form and the completion panel are mutually exclusive.</summary>
|
||||
public bool ShowSetupForm => !IsCompleted;
|
||||
|
||||
public string CompletedTitle => "Setup complete";
|
||||
public string CompletedTitle => IsContentUpdate
|
||||
? "World data updated"
|
||||
: "Setup complete";
|
||||
|
||||
public string CompletedBody =>
|
||||
"acdream built and verified your game content. You can play now.";
|
||||
public string CompletedBody => IsContentUpdate
|
||||
? _completionRequirement
|
||||
?? "acdream built and verified the required world data. You can play now."
|
||||
: "acdream built and verified your game content. You can play now.";
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the completion panel honest when prepared content is ready but
|
||||
/// cannot be paired with the active client until the update step finishes.
|
||||
/// </summary>
|
||||
public void SetCompletionRequirement(string? requirement)
|
||||
{
|
||||
_completionRequirement = string.IsNullOrWhiteSpace(requirement)
|
||||
? null
|
||||
: requirement;
|
||||
OnPropertyChanged(nameof(CompletedBody));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Switches the existing setup surface into an explicit content-update
|
||||
/// confirmation. Merely opening this surface never starts a hash or bake.
|
||||
/// </summary>
|
||||
public void PrepareContentUpdate(
|
||||
LauncherInstallRecord baseRecord,
|
||||
ContentMigrationPlan migration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(baseRecord);
|
||||
ArgumentNullException.ThrowIfNull(migration);
|
||||
_contentUpdateBase = baseRecord;
|
||||
_contentMigration = migration;
|
||||
SetCompletionRequirement(null);
|
||||
_datDirectory = baseRecord.DatDirectory;
|
||||
Status = "Review the required work. Nothing has started.";
|
||||
OnPropertyChanged(nameof(DatDirectory));
|
||||
OnPropertyChanged(nameof(IsContentUpdate));
|
||||
OnPropertyChanged(nameof(Title));
|
||||
OnPropertyChanged(nameof(Body));
|
||||
OnPropertyChanged(nameof(StartActionText));
|
||||
OnPropertyChanged(nameof(CompletedTitle));
|
||||
OnPropertyChanged(nameof(CompletedBody));
|
||||
Open();
|
||||
}
|
||||
|
||||
public void NotifyCommandStates()
|
||||
{
|
||||
|
|
@ -277,6 +333,7 @@ public sealed class FirstRunInstallerViewModel : ObservableObject, IDisposable
|
|||
{
|
||||
IsCompleted = false;
|
||||
IsOpen = false;
|
||||
ClearContentUpdateMode();
|
||||
}
|
||||
|
||||
private void Open()
|
||||
|
|
@ -343,22 +400,33 @@ public sealed class FirstRunInstallerViewModel : ObservableObject, IDisposable
|
|||
Error = null;
|
||||
ProgressPercent = 0;
|
||||
Phase = LauncherInstallPhase.ValidatingDatFiles;
|
||||
Status = "Starting installation...";
|
||||
Status = IsContentUpdate
|
||||
? "Starting the approved world-data update..."
|
||||
: "Starting installation...";
|
||||
|
||||
var progress = new CallbackProgress<LauncherInstallProgress>(value =>
|
||||
_dispatcher.Post(() => ApplyProgress(value)));
|
||||
try
|
||||
{
|
||||
LauncherInstallResult result = await _installer.InstallAsync(
|
||||
DatDirectory,
|
||||
threads,
|
||||
progress,
|
||||
cancellation.Token)
|
||||
LauncherInstallResult result = await (IsContentUpdate
|
||||
? _installer.ApplyContentUpdateAsync(
|
||||
DatDirectory,
|
||||
threads,
|
||||
_contentMigration!,
|
||||
progress,
|
||||
cancellation.Token)
|
||||
: _installer.InstallAsync(
|
||||
DatDirectory,
|
||||
threads,
|
||||
progress,
|
||||
cancellation.Token))
|
||||
.ConfigureAwait(true);
|
||||
_onInstalled(result.Record);
|
||||
Phase = LauncherInstallPhase.Completed;
|
||||
ProgressPercent = 100;
|
||||
Status = "Client content installed and verified. Launch is enabled.";
|
||||
Status = _completionRequirement is null
|
||||
? "Client content installed and verified. Launch is enabled."
|
||||
: "World data is ready. The matching game update is still required.";
|
||||
// LU4: raised only here, AFTER the record is published, so the
|
||||
// launcher behind the dialog is already in its launch-enabled
|
||||
// state when the user presses OK. The cancelled and failed
|
||||
|
|
@ -389,6 +457,44 @@ public sealed class FirstRunInstallerViewModel : ObservableObject, IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private string BuildContentUpdateBody()
|
||||
{
|
||||
ContentMigrationPlan migration = _contentMigration!;
|
||||
string work = migration.Kind == ContentWorkKind.Overlay
|
||||
? "a small filtered overlay"
|
||||
: "a complete replacement pak";
|
||||
string estimate = migration.Kind == ContentWorkKind.Overlay
|
||||
? $"Affected filters: {migration.EffectiveDatIds.Count:N0} DAT id(s), "
|
||||
+ $"{migration.EffectiveLandblocks.Count:N0} landblock(s)."
|
||||
: _contentUpdateBase is { PreparedAssetSize: > 0 } record
|
||||
? $"Free-space guidance: allow about "
|
||||
+ $"{Math.Ceiling(record.PreparedAssetSize * 1.1 / (1024d * 1024d * 1024d)):N0} GiB."
|
||||
: "Free-space guidance: allow room for one complete replacement pak.";
|
||||
return $"This client needs recipe {migration.TargetRecipeVersion}: "
|
||||
+ $"{migration.Reason}. acdream will build {work} from your installed "
|
||||
+ "Asheron's Call DAT files. The existing package stays in place "
|
||||
+ $"until the new one has finished and verified. {estimate} "
|
||||
+ "No work begins until you confirm below.";
|
||||
}
|
||||
|
||||
private void ClearContentUpdateMode()
|
||||
{
|
||||
if (_contentMigration is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_contentMigration = null;
|
||||
_contentUpdateBase = null;
|
||||
SetCompletionRequirement(null);
|
||||
OnPropertyChanged(nameof(IsContentUpdate));
|
||||
OnPropertyChanged(nameof(Title));
|
||||
OnPropertyChanged(nameof(Body));
|
||||
OnPropertyChanged(nameof(StartActionText));
|
||||
OnPropertyChanged(nameof(CompletedTitle));
|
||||
OnPropertyChanged(nameof(CompletedBody));
|
||||
}
|
||||
|
||||
private void ApplyProgress(LauncherInstallProgress progress)
|
||||
{
|
||||
if (_disposed)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable
|
|||
private readonly ILauncherUpdater _updater;
|
||||
private readonly IUiDispatcher _dispatcher;
|
||||
private readonly Action _onClientChanged;
|
||||
private readonly Func<bool> _canOpen;
|
||||
private readonly Func<bool> _canMutate;
|
||||
private readonly Func<CancellationToken, Task<bool>>? _applyLauncherUpdateAsync;
|
||||
private readonly Action? _requestShutdown;
|
||||
|
|
@ -40,6 +41,13 @@ public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable
|
|||
private double _progressPercent;
|
||||
private bool _isProgressIndeterminate;
|
||||
|
||||
/// <summary>
|
||||
/// Raised after the one startup check has either produced an authoritative
|
||||
/// result or failed. Content publication uses this edge to avoid pairing a
|
||||
/// newly prepared pak with an unconfirmed active client.
|
||||
/// </summary>
|
||||
public event EventHandler? StartupCheckCompleted;
|
||||
|
||||
/// <param name="applyLauncherUpdateAsync">Applies an already-staged
|
||||
/// launcher update against the running process, returning true when the
|
||||
/// replacement helper started and this process must now exit. Null (tests,
|
||||
|
|
@ -60,9 +68,7 @@ public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable
|
|||
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||
_onClientChanged = onClientChanged
|
||||
?? throw new ArgumentNullException(nameof(onClientChanged));
|
||||
// Retained so the composition root's call shape is unchanged; there is
|
||||
// no user-openable update panel any more, so nothing consults it.
|
||||
_ = canOpen;
|
||||
_canOpen = canOpen ?? (() => true);
|
||||
_canMutate = canMutate ?? (() => true);
|
||||
_applyLauncherUpdateAsync = applyLauncherUpdateAsync;
|
||||
_requestShutdown = requestShutdown;
|
||||
|
|
@ -174,6 +180,10 @@ public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable
|
|||
|
||||
public bool IsLauncherUpdateAvailable => _check?.IsLauncherUpdateAvailable == true;
|
||||
|
||||
public bool IsStartupCheckComplete { get; private set; }
|
||||
|
||||
public bool StartupCheckSucceeded { get; private set; }
|
||||
|
||||
/// <summary>The single affirmative action. See <see cref="UpdateAsync"/>.</summary>
|
||||
public AsyncRelayCommand UpdateCommand { get; }
|
||||
|
||||
|
|
@ -195,16 +205,24 @@ public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable
|
|||
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
_cancellation = cancellation;
|
||||
IsStartupCheckComplete = false;
|
||||
StartupCheckSucceeded = false;
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
// The composition root no longer verifies the active client before
|
||||
// the launcher window exists. Recover/verify it here, immediately
|
||||
// before the one startup feed check, while the UI is responsive.
|
||||
_ = await _updater.InitializeAsync(cancellation.Token)
|
||||
.ConfigureAwait(true);
|
||||
_check = await _updater.CheckAsync(cancellation.Token).ConfigureAwait(true);
|
||||
StartupCheckSucceeded = true;
|
||||
OnPropertyChanged(nameof(Body));
|
||||
OnPropertyChanged(nameof(IsClientUpdateAvailable));
|
||||
OnPropertyChanged(nameof(IsLauncherUpdateAvailable));
|
||||
if (HasSomethingToUpdate)
|
||||
{
|
||||
IsOpen = true;
|
||||
TryOpenPendingUpdate();
|
||||
}
|
||||
}
|
||||
catch
|
||||
|
|
@ -221,6 +239,10 @@ public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable
|
|||
}
|
||||
|
||||
IsBusy = false;
|
||||
IsStartupCheckComplete = true;
|
||||
OnPropertyChanged(nameof(IsStartupCheckComplete));
|
||||
OnPropertyChanged(nameof(StartupCheckSucceeded));
|
||||
StartupCheckCompleted?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -232,6 +254,16 @@ public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>Opens a previously discovered update once another startup
|
||||
/// question (notably required world-data work) has finished.</summary>
|
||||
public void TryOpenPendingUpdate()
|
||||
{
|
||||
if (!_disposed && HasSomethingToUpdate && _canOpen())
|
||||
{
|
||||
IsOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
public void NotifyCommandStates()
|
||||
{
|
||||
UpdateCommand.NotifyCanExecuteChanged();
|
||||
|
|
|
|||
|
|
@ -27,7 +27,15 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
private LauncherTreeNodeViewModel? _selectedNode;
|
||||
private CancellationTokenSource? _operationCancellation;
|
||||
private bool _isBusy;
|
||||
private bool _isInstallationChecking = true;
|
||||
private bool _isClientCompatibilityCheckBlocking;
|
||||
private bool _isContentUpdateRequired;
|
||||
private bool _disposed;
|
||||
private Task? _startupInitialization;
|
||||
private bool _openUpdateAfterContentCompletion;
|
||||
private bool _isClientCompatibilityPending;
|
||||
private LauncherInstallRecord? _pendingInstalledContent;
|
||||
private readonly CancellationTokenSource _startupCancellation = new();
|
||||
private string? _lastError;
|
||||
private string _operationStatus = "Ready";
|
||||
private LaunchMode _characterLaunchMode;
|
||||
|
|
@ -66,6 +74,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
EditorDialog.PropertyChanged += OnModalPropertyChanged;
|
||||
FirstRunWizardShell.PropertyChanged += OnModalPropertyChanged;
|
||||
UpdatePrompt.PropertyChanged += OnModalPropertyChanged;
|
||||
UpdatePrompt.StartupCheckCompleted += OnStartupUpdateCheckCompleted;
|
||||
|
||||
AddServerCommand = new RelayCommand(OpenAddServerDialog, () => CanInteract);
|
||||
AddAccountCommand = new RelayCommand(OpenAddAccountDialog, CanAddAccount);
|
||||
|
|
@ -195,7 +204,23 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
set => SetProperty(ref _characterLoginCommandsText, value);
|
||||
}
|
||||
|
||||
public bool IsFirstRunRequired => _snapshot is { IsInstallationReady: false };
|
||||
public bool IsInstallationChecking => _isInstallationChecking;
|
||||
|
||||
public bool IsFirstRunRequired =>
|
||||
!IsInstallationChecking
|
||||
&& (_isContentUpdateRequired
|
||||
|| _snapshot is { IsInstallationReady: false });
|
||||
|
||||
public bool ShowInstallationBanner =>
|
||||
IsInstallationChecking || IsFirstRunRequired;
|
||||
|
||||
public string InstallationBannerTitle => IsInstallationChecking
|
||||
? "Checking installation"
|
||||
: _isClientCompatibilityPending
|
||||
? "Game update required"
|
||||
: _isContentUpdateRequired
|
||||
? "World data update required"
|
||||
: "Client setup required";
|
||||
|
||||
public string InstallationStatus => _snapshot?.InstallationStatus
|
||||
?? "Installation state is loading.";
|
||||
|
|
@ -211,6 +236,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
|
||||
public bool CanLaunchAccountGuiSelect =>
|
||||
CanInteract
|
||||
&& !_isClientCompatibilityCheckBlocking
|
||||
&& IsAccountSelected
|
||||
&& TryGetSelectedAccount(out string server, out string account)
|
||||
&& _orchestrator.GetAccountLaunchCapability(
|
||||
|
|
@ -291,7 +317,29 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
}
|
||||
|
||||
RefreshFromCore();
|
||||
_ = UpdatePrompt.StartupCheckAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts only after the real launcher window has raised Opened. The exact
|
||||
/// order is intentional: content discovery first, versioned-client
|
||||
/// recovery second (inside StartupCheckAsync), network feed check last.
|
||||
/// This prevents pre-window hashing and competing startup modals.
|
||||
/// </summary>
|
||||
public Task StartBackgroundInitializationAsync()
|
||||
{
|
||||
if (_startupInitialization is not null)
|
||||
{
|
||||
return _startupInitialization;
|
||||
}
|
||||
|
||||
_isClientCompatibilityCheckBlocking = true;
|
||||
OnPropertyChanged(nameof(CanLaunchGui));
|
||||
OnPropertyChanged(nameof(CanLaunchHeadless));
|
||||
OnPropertyChanged(nameof(CanLaunchAccountGuiSelect));
|
||||
NotifyCommandStates();
|
||||
_startupInitialization = InitializeInstalledContentAndUpdatesAsync(
|
||||
_startupCancellation.Token);
|
||||
return _startupInitialization;
|
||||
}
|
||||
|
||||
public void PollStatus()
|
||||
|
|
@ -319,6 +367,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
}
|
||||
|
||||
_disposed = true;
|
||||
_startupCancellation.Cancel();
|
||||
_operationCancellation?.Cancel();
|
||||
_operationCancellation?.Dispose();
|
||||
_operationCancellation = null;
|
||||
|
|
@ -326,8 +375,91 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
EditorDialog.PropertyChanged -= OnModalPropertyChanged;
|
||||
FirstRunWizardShell.PropertyChanged -= OnModalPropertyChanged;
|
||||
UpdatePrompt.PropertyChanged -= OnModalPropertyChanged;
|
||||
UpdatePrompt.StartupCheckCompleted -= OnStartupUpdateCheckCompleted;
|
||||
FirstRunWizardShell.Dispose();
|
||||
UpdatePrompt.Dispose();
|
||||
_startupCancellation.Dispose();
|
||||
}
|
||||
|
||||
private async Task InitializeInstalledContentAndUpdatesAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
OperationStatus = "Checking installed game content…";
|
||||
try
|
||||
{
|
||||
InstallRecordVerification verification = await _installer
|
||||
.LoadExistingWithProgressAsync(
|
||||
cancellationToken,
|
||||
progress: new Progress<string>(status =>
|
||||
OperationStatus = status))
|
||||
.ConfigureAwait(true);
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isContentUpdateRequired = verification.RequiresContentUpdate;
|
||||
if (verification.IsVerified
|
||||
&& verification.Record is
|
||||
{ RequiresClientCompatibilityConfirmation: true } pendingRecord)
|
||||
{
|
||||
_pendingInstalledContent = pendingRecord;
|
||||
_isClientCompatibilityPending = true;
|
||||
_orchestrator.SetInstallationState(null, verification.Status);
|
||||
}
|
||||
else
|
||||
{
|
||||
_orchestrator.SetInstallationState(
|
||||
verification.IsVerified || verification.RequiresContentUpdate
|
||||
? verification.Record
|
||||
: null,
|
||||
verification.Status);
|
||||
}
|
||||
|
||||
OperationStatus = verification.Status;
|
||||
if (verification.RequiresContentUpdate
|
||||
&& verification.Record is not null
|
||||
&& verification.RequiredContentWork is not null)
|
||||
{
|
||||
FirstRunWizardShell.PrepareContentUpdate(
|
||||
verification.Record,
|
||||
verification.RequiredContentWork);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string status = "Client content verification failed: "
|
||||
+ SafeDisplayError(ex, secret: null);
|
||||
_orchestrator.SetInstallationState(null, status);
|
||||
OperationStatus = status;
|
||||
LastError = status;
|
||||
}
|
||||
if (!_disposed && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
OperationStatus = "Checking for game updates…";
|
||||
await UpdatePrompt.StartupCheckAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
if (!_disposed)
|
||||
{
|
||||
_isClientCompatibilityCheckBlocking = false;
|
||||
_isInstallationChecking = false;
|
||||
OnPropertyChanged(nameof(IsInstallationChecking));
|
||||
OnPropertyChanged(nameof(IsFirstRunRequired));
|
||||
OnPropertyChanged(nameof(ShowInstallationBanner));
|
||||
OnPropertyChanged(nameof(InstallationBannerTitle));
|
||||
OnPropertyChanged(nameof(InstallationStatus));
|
||||
RefreshFromCore();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnOrchestratorStateChanged(object? sender, EventArgs e) =>
|
||||
|
|
@ -356,6 +488,14 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
OnPropertyChanged(nameof(ShowGuiLaunchDisabledReason));
|
||||
OnPropertyChanged(nameof(ShowHeadlessLaunchDisabledReason));
|
||||
NotifyCommandStates();
|
||||
if (ReferenceEquals(sender, FirstRunWizardShell)
|
||||
&& e.PropertyName == nameof(FirstRunInstallerViewModel.IsOpen)
|
||||
&& !FirstRunWizardShell.IsOpen
|
||||
&& _openUpdateAfterContentCompletion)
|
||||
{
|
||||
_openUpdateAfterContentCompletion = false;
|
||||
UpdatePrompt.TryOpenPendingUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public void CloseActiveModal()
|
||||
|
|
@ -403,6 +543,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
SetSelectedNode(restored, preserveDraft);
|
||||
|
||||
OnPropertyChanged(nameof(IsFirstRunRequired));
|
||||
OnPropertyChanged(nameof(ShowInstallationBanner));
|
||||
OnPropertyChanged(nameof(InstallationStatus));
|
||||
OnPropertyChanged(nameof(ShowLinuxGraphicalNotice));
|
||||
OnPropertyChanged(nameof(LinuxGraphicalNotice));
|
||||
|
|
@ -832,6 +973,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
|
||||
private bool CanLaunch(LaunchMode mode) =>
|
||||
CanInteract
|
||||
&& !_isClientCompatibilityCheckBlocking
|
||||
&& IsCharacterSelected
|
||||
&& TryGetSelectedAccount(out string server, out string account)
|
||||
&& _orchestrator.GetAccountLaunchCapability(server, account, mode).IsAvailable;
|
||||
|
|
@ -967,11 +1109,21 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
try
|
||||
{
|
||||
InstallRecordVerification verification = await _installer
|
||||
.LoadExistingAsync(cancellation.Token, forceFullVerification: true)
|
||||
.LoadExistingWithProgressAsync(
|
||||
cancellation.Token,
|
||||
forceFullVerification: true,
|
||||
progress: new Progress<string>(status =>
|
||||
OperationStatus = status))
|
||||
.ConfigureAwait(true);
|
||||
_orchestrator.SetInstallRecord(verification.Record);
|
||||
_isContentUpdateRequired = verification.RequiresContentUpdate;
|
||||
_orchestrator.SetInstallationState(
|
||||
verification.IsVerified || verification.RequiresContentUpdate
|
||||
? verification.Record
|
||||
: null,
|
||||
verification.Status);
|
||||
OperationStatus = verification.Status;
|
||||
if (!verification.IsVerified)
|
||||
if (!verification.IsVerified
|
||||
&& !verification.RequiresContentUpdate)
|
||||
{
|
||||
LastError = verification.Status;
|
||||
}
|
||||
|
|
@ -995,19 +1147,93 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
|||
|
||||
private void OnInstallCompleted(LauncherInstallRecord record)
|
||||
{
|
||||
_orchestrator.SetInstallRecord(record);
|
||||
OperationStatus = "Client content installed and verified.";
|
||||
bool attemptImmediateCompatibilityConfirmation = false;
|
||||
_isContentUpdateRequired = false;
|
||||
_openUpdateAfterContentCompletion = true;
|
||||
if (FirstRunWizardShell.IsContentUpdate
|
||||
&& (record.RequiresClientCompatibilityConfirmation
|
||||
|| !UpdatePrompt.IsStartupCheckComplete
|
||||
|| !UpdatePrompt.StartupCheckSucceeded
|
||||
|| UpdatePrompt.IsClientUpdateAvailable))
|
||||
{
|
||||
_pendingInstalledContent = record;
|
||||
_isClientCompatibilityPending = true;
|
||||
const string requirement = "acdream built and verified the world data. "
|
||||
+ "Install the matching game update next; Play stays disabled until it finishes.";
|
||||
FirstRunWizardShell.SetCompletionRequirement(requirement);
|
||||
_orchestrator.SetInstallationState(
|
||||
null,
|
||||
"World data is ready; install the matching game update before playing.");
|
||||
OperationStatus = "World data is ready; matching game update required.";
|
||||
attemptImmediateCompatibilityConfirmation = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_orchestrator.SetInstallRecord(record);
|
||||
OperationStatus = "Client content installed and verified.";
|
||||
}
|
||||
|
||||
LastError = null;
|
||||
RefreshFromCore();
|
||||
if (attemptImmediateCompatibilityConfirmation)
|
||||
{
|
||||
PublishPendingContentIfCompatible(clientWasInstalled: false);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnClientVersionChanged()
|
||||
{
|
||||
PublishPendingContentIfCompatible(clientWasInstalled: true);
|
||||
OperationStatus = "Versioned client activation changed.";
|
||||
LastError = null;
|
||||
RefreshFromCore();
|
||||
}
|
||||
|
||||
private void OnStartupUpdateCheckCompleted(object? sender, EventArgs e) =>
|
||||
PublishPendingContentIfCompatible(clientWasInstalled: false);
|
||||
|
||||
private void PublishPendingContentIfCompatible(bool clientWasInstalled)
|
||||
{
|
||||
if (_pendingInstalledContent is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool compatible = clientWasInstalled
|
||||
|| (UpdatePrompt.StartupCheckSucceeded
|
||||
&& !UpdatePrompt.IsClientUpdateAvailable);
|
||||
if (!compatible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LauncherInstallRecord pendingRecord = _pendingInstalledContent;
|
||||
try
|
||||
{
|
||||
_installer.ConfirmClientCompatibility();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
string status = "The matching client is ready, but the content gate "
|
||||
+ "could not be cleared: " + SafeDisplayError(ex, secret: null);
|
||||
_orchestrator.SetInstallationState(null, status);
|
||||
OperationStatus = status;
|
||||
LastError = status;
|
||||
return;
|
||||
}
|
||||
|
||||
LauncherInstallRecord record = pendingRecord with
|
||||
{
|
||||
RequiresClientCompatibilityConfirmation = false,
|
||||
};
|
||||
_pendingInstalledContent = null;
|
||||
_isClientCompatibilityPending = false;
|
||||
FirstRunWizardShell.SetCompletionRequirement(null);
|
||||
_orchestrator.SetInstallRecord(record);
|
||||
OperationStatus = "Client and world data are installed and verified.";
|
||||
OnPropertyChanged(nameof(InstallationBannerTitle));
|
||||
}
|
||||
|
||||
private void NotifyCommandStates()
|
||||
{
|
||||
AddServerCommand.NotifyCanExecuteChanged();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
209
tests/AcDream.Content.Tests/LayeredPreparedAssetSourceTests.cs
Normal file
209
tests/AcDream.Content.Tests/LayeredPreparedAssetSourceTests.cs
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue