diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 184e26e4..9487b18b 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -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 diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md index a449b22b..f72fc1ad 100644 --- a/docs/architecture/worldbuilder-inventory.md +++ b/docs/architecture/worldbuilder-inventory.md @@ -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 diff --git a/docs/plans/2026-08-25-launcher-content-stabilization.md b/docs/plans/2026-08-25-launcher-content-stabilization.md new file mode 100644 index 00000000..ada1a92a --- /dev/null +++ b/docs/plans/2026-08-25-launcher-content-stabilization.md @@ -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": "", + "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. diff --git a/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs b/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs index 5b6e72a3..ae4e4d58 100644 --- a/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs +++ b/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs @@ -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 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 diagnostic) => - new PakPreparedAssetSource(path, dats, diagnostic); + Action 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( diff --git a/src/AcDream.App/Configuration/SessionConfiguration.cs b/src/AcDream.App/Configuration/SessionConfiguration.cs index ec225785..8e55c922 100644 --- a/src/AcDream.App/Configuration/SessionConfiguration.cs +++ b/src/AcDream.App/Configuration/SessionConfiguration.cs @@ -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 diff --git a/src/AcDream.App/Configuration/SessionConfigurationLoader.cs b/src/AcDream.App/Configuration/SessionConfigurationLoader.cs index 086cb11e..eaab04a3 100644 --- a/src/AcDream.App/Configuration/SessionConfigurationLoader.cs +++ b/src/AcDream.App/Configuration/SessionConfigurationLoader.cs @@ -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) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b0c4b52f..11ef0f3a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1418,6 +1418,9 @@ public sealed class GameWindow : new ContentEffectsAudioDependencies( _datDir, _options.PreparedAssetPath, + _options.PreparedAssetOverlayPath, + _options.PreparedAssetBaseRecipeVersion, + _options.PreparedAssetEffectiveRecipeVersion, _options.ResidencyBudgets, _physicsDataCache, _animationDiagnostics.DumpMotionEnabled, diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 22e6679a..53fb6a53 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -116,6 +116,12 @@ public sealed record RuntimeOptions( /// , milliseconds. int LoginCommandDelayMs) { + public string? PreparedAssetOverlayPath { get; init; } + + public uint? PreparedAssetBaseRecipeVersion { get; init; } + + public uint? PreparedAssetEffectiveRecipeVersion { get; init; } + /// /// Build options from the process environment. Used by /// Program.cs 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. diff --git a/src/AcDream.Content/IPreparedAssetSource.cs b/src/AcDream.Content/IPreparedAssetSource.cs index f9f58659..468e9857 100644 --- a/src/AcDream.Content/IPreparedAssetSource.cs +++ b/src/AcDream.Content/IPreparedAssetSource.cs @@ -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); } } diff --git a/src/AcDream.Content/LayeredPreparedAssetSource.cs b/src/AcDream.Content/LayeredPreparedAssetSource.cs new file mode 100644 index 00000000..1192c76a --- /dev/null +++ b/src/AcDream.Content/LayeredPreparedAssetSource.cs @@ -0,0 +1,239 @@ +using AcDream.Content.Pak; +using AcDream.Core.Physics; + +namespace AcDream.Content; + +/// +/// 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. +/// +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 + ReadGfxObjCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) + { + PreparedCollisionReadResult overlay = + Require(_overlayCollision).ReadGfxObjCollision( + sourceFileId, + cancellationToken); + return overlay.Status == PreparedAssetReadStatus.Missing + ? Require(_baseCollision).ReadGfxObjCollision( + sourceFileId, + cancellationToken) + : overlay; + } + + public PreparedCollisionReadResult + ReadSetupCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) + { + PreparedCollisionReadResult overlay = + Require(_overlayCollision).ReadSetupCollision( + sourceFileId, + cancellationToken); + return overlay.Status == PreparedAssetReadStatus.Missing + ? Require(_baseCollision).ReadSetupCollision( + sourceFileId, + cancellationToken) + : overlay; + } + + public PreparedCollisionReadResult + ReadCellStructureCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) + { + PreparedCollisionReadResult overlay = + Require(_overlayCollision).ReadCellStructureCollision( + sourceFileId, + cancellationToken); + return overlay.Status == PreparedAssetReadStatus.Missing + ? Require(_baseCollision).ReadCellStructureCollision( + sourceFileId, + cancellationToken) + : overlay; + } + + public PreparedCollisionReadResult + ReadEnvCellTopology( + uint sourceFileId, + CancellationToken cancellationToken = default) + { + PreparedCollisionReadResult 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? 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? value) + where T : class => + value ?? throw new ObjectDisposedException( + nameof(LayeredPreparedAssetSource)); + + private static void DisposeOne( + IDisposable? value, + ref List? failures) + { + if (value is null) + { + return; + } + + try + { + value.Dispose(); + } + catch (Exception exception) + { + (failures ??= []).Add(exception); + } + } +} diff --git a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs index 6bf5f0e1..76e2eacf 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs @@ -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 diff --git a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs index 389b2a6f..d6fb73d8 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs @@ -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( diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessContentOwner.cs b/src/AcDream.Headless/Hosting/HeadlessProcessContentOwner.cs index 5c42ea75..45286e52 100644 --- a/src/AcDream.Headless/Hosting/HeadlessProcessContentOwner.cs +++ b/src/AcDream.Headless/Hosting/HeadlessProcessContentOwner.cs @@ -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(0x13000000u) ?? throw new InvalidOperationException( diff --git a/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs b/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs index 24fbc48c..075b34e2 100644 --- a/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs +++ b/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs @@ -11,18 +11,43 @@ public sealed record BakeProcessRequest( string DatDirectory, string OutputPath, int Threads, - string? PublicationNonce = null) + string? PublicationNonce = null, + IReadOnlyList? DatIds = null, + IReadOnlyList? Landblocks = null) { - public IReadOnlyList Arguments => - [ - "--dat-dir", - DatDirectory, - "--out", - OutputPath, - "--threads", - Threads.ToString(CultureInfo.InvariantCulture), - "--progress-json", - ]; + public IReadOnlyList Arguments + { + get + { + var arguments = new List + { + "--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); diff --git a/src/AcDream.Launcher.Core/Installation/ContentMigrationCatalog.cs b/src/AcDream.Launcher.Core/Installation/ContentMigrationCatalog.cs new file mode 100644 index 00000000..500d5fc9 --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/ContentMigrationCatalog.cs @@ -0,0 +1,127 @@ +namespace AcDream.Launcher.Core.Installation; + +/// The only four prepared-content actions exposed to the launcher UI. +public enum ContentWorkKind +{ + None, + Overlay, + FullRebuild, + Verify, +} + +/// +/// 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. +/// +public sealed record ContentMigrationPlan( + uint FromRecipeVersion, + uint TargetRecipeVersion, + ContentWorkKind Kind, + string Reason, + IReadOnlyList? DatIds = null, + IReadOnlyList? Landblocks = null) +{ + public IReadOnlyList EffectiveDatIds => DatIds ?? []; + + public IReadOnlyList EffectiveLandblocks => Landblocks ?? []; +} + +/// +/// 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. +/// +public static class ContentMigrationCatalog +{ + private static readonly IReadOnlyDictionary Steps = + new Dictionary + { + [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(); + var landblocks = new HashSet(); + var reasons = new List(); + 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); +} diff --git a/src/AcDream.Launcher.Core/Installation/LauncherContentStateStore.cs b/src/AcDream.Launcher.Core/Installation/LauncherContentStateStore.cs new file mode 100644 index 00000000..2a14452b --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/LauncherContentStateStore.cs @@ -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; +} + +/// +/// 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. +/// +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> _computeSha256; + + public LauncherContentStateStore( + ApplicationPathSet paths, + Func>? 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( + 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 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; + } +} diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs index 1b52cf84..f96badb7 100644 --- a/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs +++ b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs @@ -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; } /// @@ -77,7 +82,8 @@ public sealed class LauncherInstallRecordStore /// passes false. public async Task LoadAndVerifyAsync( CancellationToken cancellationToken = default, - bool forceFullVerification = false) + bool forceFullVerification = false, + IProgress? 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 LoadAndVerifyUnderLeaseAsync( CancellationToken cancellationToken = default, - bool forceFullVerification = false) + bool forceFullVerification = false, + IProgress? 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 } } + /// 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. + 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? 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)) diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs index 4deae741..ea5b9949 100644 --- a/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs +++ b/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs @@ -61,25 +61,55 @@ public interface ILauncherInstaller CancellationToken cancellationToken = default, bool forceFullVerification = false); + Task LoadExistingWithProgressAsync( + CancellationToken cancellationToken = default, + bool forceFullVerification = false, + IProgress? progress = null) => + LoadExistingAsync(cancellationToken, forceFullVerification); + Task InstallAsync( string datDirectory, int threads, IProgress? progress = null, CancellationToken cancellationToken = default); + + Task ApplyContentUpdateAsync( + string datDirectory, + int threads, + ContentMigrationPlan migration, + IProgress? progress = null, + CancellationToken cancellationToken = default) => + migration.Kind == ContentWorkKind.FullRebuild + ? InstallAsync( + datDirectory, + threads, + progress, + cancellationToken) + : Task.FromException( + new NotSupportedException( + "This installer does not support filtered content overlays.")); + + /// Clears the crash-safe gate left by a completed content + /// migration after the active client has been confirmed compatible. + void ConfirmClientCompatibility() + { + } } /// /// 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. /// 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> _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>? 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 LoadExistingAsync( CancellationToken cancellationToken = default, - bool forceFullVerification = false) + bool forceFullVerification = false) => + await LoadExistingWithProgressAsync( + cancellationToken, + forceFullVerification) + .ConfigureAwait(false); + + public async Task LoadExistingWithProgressAsync( + CancellationToken cancellationToken = default, + bool forceFullVerification = false, + IProgress? 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 RecoverExistingUnderPublicationGuardAsync( CancellationToken cancellationToken, - bool forceFullVerification = false) + bool forceFullVerification = false, + IProgress? 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 ApplyContentUpdateAsync( + string datDirectory, + int threads, + ContentMigrationPlan migration, + IProgress? 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 InstallOverlayCoreAsync( + string datDirectory, + int threads, + ContentMigrationPlan migration, + IProgress? 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 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, diff --git a/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs b/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs index c89c279a..92b71a12 100644 --- a/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs +++ b/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace AcDream.Launcher.Core.Launching; /// @@ -23,4 +25,29 @@ public sealed record LauncherInstallRecord( && PreparedAssetSha256.Length == 64 && PreparedAssetSize > 0 && BakeToolVersion > 0; + + /// + /// 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. + /// + [JsonIgnore] + public string? PreparedAssetOverlayPath { get; init; } + + [JsonIgnore] + public uint EffectiveBakeToolVersion { get; init; } + + /// + /// 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. + /// + [JsonIgnore] + public bool RequiresClientCompatibilityConfirmation { get; init; } + + [JsonIgnore] + public uint ResolvedBakeToolVersion => + EffectiveBakeToolVersion == 0 + ? BakeToolVersion + : EffectiveBakeToolVersion; } diff --git a/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs b/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs index d691ff1d..3eef5614 100644 --- a/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs +++ b/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs @@ -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); diff --git a/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs b/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs index 1fc31f4d..83d00df8 100644 --- a/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs +++ b/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs @@ -70,6 +70,15 @@ public sealed class SessionContentDescriptor public string DatDirectory { get; init; } = string.Empty; public string PreparedAssetPath { get; init; } = string.Empty; + + /// Present only for a layered prepared-content session. + public string? PreparedAssetOverlayPath { get; init; } + + /// Present with an overlay so the client can validate both pak + /// headers without assuming the base uses its current recipe. + public uint? PreparedAssetBaseRecipeVersion { get; init; } + + public uint? PreparedAssetEffectiveRecipeVersion { get; init; } } public sealed class SessionDescriptor diff --git a/src/AcDream.Launcher.Core/Orchestration/ILauncherOrchestrator.cs b/src/AcDream.Launcher.Core/Orchestration/ILauncherOrchestrator.cs index e52c443f..377d9f03 100644 --- a/src/AcDream.Launcher.Core/Orchestration/ILauncherOrchestrator.cs +++ b/src/AcDream.Launcher.Core/Orchestration/ILauncherOrchestrator.cs @@ -27,6 +27,17 @@ public interface ILauncherOrchestrator : IDisposable void SetInstallRecord(LauncherInstallRecord? installRecord); + /// + /// Publishes the exact result of asynchronous launcher content discovery. + /// The default keeps test/injected implementations source-compatible; + /// production additionally retains + /// so a failed check is not flattened into a misleading first-run message. + /// + 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); diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs index d52acae7..8939e2a4 100644 --- a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs @@ -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(); diff --git a/src/AcDream.Launcher/App.axaml.cs b/src/AcDream.Launcher/App.axaml.cs index 73ed6bb3..d0f49756 100644 --- a/src/AcDream.Launcher/App.axaml.cs +++ b/src/AcDream.Launcher/App.axaml.cs @@ -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(); diff --git a/src/AcDream.Launcher/LauncherUpdateComposition.cs b/src/AcDream.Launcher/LauncherUpdateComposition.cs index e828c8b6..b2b86391 100644 --- a/src/AcDream.Launcher/LauncherUpdateComposition.cs +++ b/src/AcDream.Launcher/LauncherUpdateComposition.cs @@ -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 { diff --git a/src/AcDream.Launcher/MainWindow.axaml b/src/AcDream.Launcher/MainWindow.axaml index 7d877b3d..5b05836e 100644 --- a/src/AcDream.Launcher/MainWindow.axaml +++ b/src/AcDream.Launcher/MainWindow.axaml @@ -56,14 +56,15 @@ + IsVisible="{Binding ShowInstallationBanner}"> - + public List LoadExistingCalls { get; } = []; + public Func>? + LoadExistingHandler { get; set; } + public Task 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 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? CheckGate { get; init; } + + public ClientVersionResolution CurrentClient => _resolution; + + public Task InitializeAsync( + CancellationToken cancellationToken = default) + { + InitializeCalls++; + return Task.FromResult(_resolution); + } + + public async Task 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 { ["win-x64"] = artifact }, + new Dictionary { ["win-x64"] = artifact }); + return new LauncherUpdateCheckResult( + manifest, + "win-x64", + Version, + Version, + IsClientUpdateAvailable: ClientUpdateAvailable, + IsLauncherUpdateAvailable: false, + IsLauncherMinimumSatisfied: true, + "Everything is current."); + } + + public Task InstallClientAsync( + LauncherUpdateCheckResult check, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + InstallCalls++; + return Task.FromResult(_resolution); + } + + public Task StageLauncherAsync( + LauncherUpdateCheckResult check, + IProgress? progress = null, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public Task RollbackClientAsync( + IProgress? progress = null, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); } }