diff --git a/.github/workflows/headless-portability.yml b/.github/workflows/headless-portability.yml index 6cea0898..757827b9 100644 --- a/.github/workflows/headless-portability.yml +++ b/.github/workflows/headless-portability.yml @@ -5,6 +5,10 @@ on: paths: - ".github/workflows/headless-portability.yml" - "AcDream.slnx" + - "src/AcDream.Platform/**" + - "src/AcDream.Launcher.Core/**" + - "src/AcDream.Launcher/**" + - "src/AcDream.Bake/**" - "src/AcDream.Core/**" - "src/AcDream.Core.Net/**" - "src/AcDream.Content/**" @@ -13,6 +17,10 @@ on: - "src/AcDream.Headless/**" - "src/AcDream.App/**" - "src/AcDream.UI.Abstractions/**" + - "tests/AcDream.Platform.Tests/**" + - "tests/AcDream.Launcher.Core.Tests/**" + - "tests/AcDream.Launcher.Tests/**" + - "tests/AcDream.Bake.Tests/**" - "tests/AcDream.Core.Tests/**" - "tests/AcDream.Core.Net.Tests/**" - "tests/AcDream.Content.Tests/**" @@ -20,12 +28,17 @@ on: - "tests/AcDream.Headless.Tests/**" - "tests/AcDream.App.Tests/**" - "tests/AcDream.UI.Abstractions.Tests/**" + - "tests/Fixtures/campaign-la/**" - "tools/ShaderCompiler/**" - "tools/compile-shaders.ps1" push: paths: - ".github/workflows/headless-portability.yml" - "AcDream.slnx" + - "src/AcDream.Platform/**" + - "src/AcDream.Launcher.Core/**" + - "src/AcDream.Launcher/**" + - "src/AcDream.Bake/**" - "src/AcDream.Core/**" - "src/AcDream.Core.Net/**" - "src/AcDream.Content/**" @@ -34,6 +47,10 @@ on: - "src/AcDream.Headless/**" - "src/AcDream.App/**" - "src/AcDream.UI.Abstractions/**" + - "tests/AcDream.Platform.Tests/**" + - "tests/AcDream.Launcher.Core.Tests/**" + - "tests/AcDream.Launcher.Tests/**" + - "tests/AcDream.Bake.Tests/**" - "tests/AcDream.Core.Tests/**" - "tests/AcDream.Core.Net.Tests/**" - "tests/AcDream.Content.Tests/**" @@ -41,6 +58,7 @@ on: - "tests/AcDream.Headless.Tests/**" - "tests/AcDream.App.Tests/**" - "tests/AcDream.UI.Abstractions.Tests/**" + - "tests/Fixtures/campaign-la/**" - "tools/ShaderCompiler/**" - "tools/compile-shaders.ps1" workflow_dispatch: @@ -66,7 +84,7 @@ jobs: dotnet-version: "10.0.x" # No apt step here on purpose. This job's whole claim is that the closure - # below is presentation-free: it builds Plugin.Abstractions, Core, + # below is presentation-free: it builds Bake, Plugin.Abstractions, Core, # Core.Net, Content, Runtime and Headless, runs their tests, and invokes # the Headless CLI. Nothing in it opens a display, links GL, or calls # xvfb-run, so an "install the graphical smoke dependencies" step here was @@ -78,6 +96,9 @@ jobs: shell: pwsh run: | $projects = @( + "src/AcDream.Platform/AcDream.Platform.csproj", + "src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj", + "src/AcDream.Bake/AcDream.Bake.csproj", "src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj", "src/AcDream.Core/AcDream.Core.csproj", "src/AcDream.Core.Net/AcDream.Core.Net.csproj", @@ -98,6 +119,9 @@ jobs: shell: pwsh run: | $projects = @( + "tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj", + "tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj", + "tests/AcDream.Bake.Tests/AcDream.Bake.Tests.csproj", "tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj", "tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj", "tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj", @@ -117,6 +141,89 @@ jobs: dotnet run --project src/AcDream.Headless/AcDream.Headless.csproj -c Release -- validate --config headless-k0.json if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Verify Linux headless host executable permission + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + test -x src/AcDream.Headless/bin/Release/net10.0/acdream-headless + + portable-launcher: + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install .NET 10 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + - name: Build and test the portable launcher + shell: pwsh + run: | + dotnet build src/AcDream.Launcher/AcDream.Launcher.csproj -c Release + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + dotnet test tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj -c Release + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Publish the self-contained launcher distribution + shell: pwsh + run: | + $rid = if ($IsWindows) { "win-x64" } else { "linux-x64" } + dotnet publish src/AcDream.Launcher/AcDream.Launcher.csproj ` + -c Release ` + -r $rid ` + -o "artifacts/acdream-launcher-$rid" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Verify self-contained Windows launcher and bake artifacts + if: runner.os == 'Windows' + shell: pwsh + run: | + $root = "artifacts/acdream-launcher-win-x64" + if (-not (Test-Path -LiteralPath "$root/acdream-launcher.exe" -PathType Leaf)) { throw "launcher executable missing" } + if (-not (Test-Path -LiteralPath "$root/acdream-bake.exe" -PathType Leaf)) { throw "bake executable missing" } + if (Test-Path -LiteralPath "$root/acdream-launcher.dll") { throw "launcher is not single-file" } + if (Test-Path -LiteralPath "$root/acdream-bake.dll") { throw "bake is not single-file" } + $env:DOTNET_ROOT = "Z:\definitely-not-installed" + $env:DOTNET_ROOT_X64 = "Z:\definitely-not-installed" + $env:DOTNET_MULTILEVEL_LOOKUP = "0" + & "$root/acdream-launcher.exe" --verify-publish + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & "$root/acdream-bake.exe" --help + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Verify self-contained Linux launcher and bake artifacts + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + root=artifacts/acdream-launcher-linux-x64 + self_contained=$(dotnet msbuild \ + src/AcDream.Launcher/AcDream.Launcher.csproj \ + -nologo \ + -property:RuntimeIdentifier=linux-x64 \ + -getProperty:SelfContained | tr -d '\r\n ') + test "$self_contained" = true + test -x "$root/acdream-launcher" + test -x "$root/acdream-bake" + test ! -f "$root/acdream-launcher.dll" + test ! -f "$root/acdream-bake.dll" + DOTNET_ROOT=/definitely-not-installed \ + DOTNET_ROOT_X64=/definitely-not-installed \ + DOTNET_MULTILEVEL_LOOKUP=0 \ + "$root/acdream-launcher" --verify-publish + DOTNET_ROOT=/definitely-not-installed \ + DOTNET_ROOT_X64=/definitely-not-installed \ + DOTNET_MULTILEVEL_LOOKUP=0 \ + "$root/acdream-bake" --help + linux-graphical: runs-on: ubuntu-latest diff --git a/AcDream.slnx b/AcDream.slnx index d28db1d2..b003cfca 100644 --- a/AcDream.slnx +++ b/AcDream.slnx @@ -7,6 +7,9 @@ + + + @@ -24,6 +27,13 @@ + + + + + + + diff --git a/CLAUDE.md b/CLAUDE.md index ad590b94..256acf73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -232,6 +232,66 @@ J-owner, both retail open paths, staged-item trading marker AD-93/AD-94 filed, AD-85 narrowed, AD-81 amended, AD-89/AD-95 retired. Filed: #393 (texture-detail options, post-M4). +**Campaign LA — the alpha launcher (ACTIVE 2026-08-14):** Avalonia +launcher/installer/updater (Windows+Linux) + the retail character- +management screen, driven autonomously under a user-set goal: Fable +plans, Sonnet implements, Opus dual-lens reviews (architectural + +retail-faithful). Spec: +`docs/superpowers/specs/2026-08-14-launcher-campaign-design.md`; plan + +ledger: `docs/plans/2026-08-14-launcher-campaign.md`; START at +`claude-memory/project_launcher_direction.md`. Key recon corrections +already binding: retail's select screen (`gmCharacterManagementUI`) has +NO 3D preview (chargen-only machinery); UI Studio no longer exists +(deleted at Campaign V — ignore stale memory/docs claims otherwise); +App `Program.cs` has no subcommand dispatch (the `--session-config` flag +is additive). +LA0 through LA11's automated scope are review-closed. The launcher composer is now +compiled into both host test suites, and Launcher.Core runs in the portable +Windows/Ubuntu CI closure. The self-contained Avalonia launcher, +transactional two-host plugin lifetime, shared login-command route, +Runtime-owned retail selection state, authored DAT character screen, and +crash-safe verified installer plus atomic cross-platform updater/self-updater +are integrated. Windows group-isolated Headless stop, isolated update fixtures, +strict status/redaction evidence, and the exact Windows/Ubuntu operator script +are landed; the integrated preflight passes 32/32 commands and 14,012 tests / +5 skips. Only the connected/visual/real-DAT user gate remains before shipment. + +**Campaign CC — retail character creation (CLOSED USER-ACCEPTED +2026-08-16).** All seven slices REVIEW-CLOSED; the connected gate ran as +one extended round (findings GF-1..16 + re-tests R2/R3/R4, fix batches +A-G + closeout + two re-test rounds, final build `1.0.2-cc.o`) and +PASSED. **Milestone: the first live character ever created by acdream +against ACE landed mid-round.** The gate round's own harvest hardened +shared surfaces well beyond chargen: authored text margins (P0x23-26), +the authored Unselected/Selected state pair + per-state label color, +un-consumed Type-12 media children (frames/scrollbars client-wide), +single-sprite scrollbar thumbs, UiButton/UiDatElement Tint, the +dialog-always-on-top re-raise (the invisible-modal input blackhole), a +truthful client crash self-report + bounded stderr capture (#405-#407 +fixed, #406 fixed; #408/#409/#410 filed for their own rounds). The full retail creation flow: Create +button (retail's exact `UpdateButtons` rosterm_eVerticalJustification = 4` (and + `m_eHorizontalJustification = 2` at `@0x004685f5`) BEFORE any dat + property is applied — i.e. retail's real unauthored default is the raw + value **4**, not whatever a "sensible default" might suggest. +- `UIElement_Text::CalcJustification` `@0x00467260`: the ACTUAL enum + semantics, shared by both the horizontal and vertical branches via one + `ecx_5` comparison — `ecx_5 == 1` → **Center**; `ecx_5 == 3 || ecx_5 == 5` + → the FAR edge (**Right** for horizontal, **Bottom** for vertical); any + OTHER value (0, 2, 4, ...) → `edi = 0`, the NEAR edge (**Left** for + horizontal, **Top** for vertical). + +Cross-referencing: the ctor's own vertical default of 4 resolves via this +real semantic table to **Top**, not Center. This port's +`ElementReader.cs:507`'s import-time switch (`2u=>Top, 4u=>Bottom, +_=>Center`) and `DatWidgetFactory.cs:704`'s build-time switch are BOTH +wrong relative to the real table — only raw value `2` (coincidentally +falling into the correct "near edge" bucket) and `1` (Center, matching the +`_=>Center` catch-all by coincidence) currently resolve correctly; `0`, +`3`, `4`, and `5` all resolve to the wrong bucket. The `ElementInfo.VJustify` +field default (`VJustify.Center`) is ALSO wrong — it should be `Top` to +match the ctor's real resolved value. + +**Why this is filed instead of fixed here:** the blast radius is +client-wide — every DAT-imported `UiText` that reaches the +`Centered`/`RightAligned`/`OneLine` static paths or the multi-line +honored-justification path (`_honorDatVerticalJustification`, set +unconditionally by `ConfigureDatState` for every DAT-imported text +element) is affected, including already-shipped, visually-verified, +FROZEN surfaces (vitals numbers, chat, main game UI, Options panel) that +may be relying on the CURRENT (wrong) Center default for their existing +correct-looking vertical alignment. Flipping the shared default/mapping +without a full client-wide regression sweep risks reintroducing +regressions in surfaces this session has no budget to re-verify. R3-3's +own fix (`CharacterCreationSkillsPage`'s constructor) scopes the +correction to ONLY the two Skills info-box panes via an explicit +`VerticalJustify = VJustify.Top` post-construction assignment — a +targeted, decomp-grounded correction that does not touch the shared +mapping. + +**Fix direction when this issue is picked up:** (1) correct +`ElementReader.cs`'s import-time switch AND `DatWidgetFactory.cs`'s +build-time switch to the real table above (`1=>Center, 3 or 5=>Bottom, +else=>Top`) for BOTH horizontal and vertical justification (audit the +horizontal switch too — it currently special-cases `0u or 2u=>Left` +instead of "everything except 1/3/5"; likely benign today since 2 is the +only unauthored horizontal default in practice, but should be corrected +for the same reason); (2) flip `ElementInfo.VJustify`'s field default to +`Top`; (3) fix `ElementReader.cs:435`'s `Merge` sentinel +(`derived.VJustify != VJustify.Center ? derived : base_`) to use the NEW +default (`Top`) as the "unset" sentinel instead, or restructure to a +nullable/explicit-override tracking shape so the merge doesn't rely on a +magic default value at all; (4) a full client-wide live-DAT sweep of every +Type-12/Button element that authors OR omits property `0x15`/`0x14`, +cross-checked against a fresh full visual pass of chat, main game UI, +Options, and every chargen page (this port's own `CharacterCreationSkillsPage` +override from R3-3 should be REMOVED once the shared default is corrected, +since it would then be redundant); (5) the exact same audit for the +horizontal `HJustify` mapping while in this code, since it shares the +`CalcJustification` function and the same class of latent bug. + +## #409 — Client-wide UI tooltip system is unshipped (GF-16, deferred out of Campaign CC gate round 1) + +**Status:** OPEN +**Severity:** LOW-MEDIUM (cosmetic/discoverability — no gameplay impact, but retail shows a tooltip on hover for ~253 authored elements client-wide and acdream shows none) + +Found during Campaign CC gate round 1's Batch D root-cause investigation +(`docs/research/2026-08-16-campaign-cc-gate-round1-findings.md`, GF-16 +"Hover tooltips missing on all pages"). Explicitly out of Batch D's own +scope — Batch D fixed the chargen 3D preview backdrop (GF-7/GF-14) only; +GF-16 is a CLIENT-WIDE mechanism, not a chargen-scoped one, and needs its +own gate round the same way GF-12's frame carve-out and #408's +importer-wide honor did. + +Retail's tooltip pipeline (decomp anchors from the Batch D +investigation): + +- `UIElement::StartTooltipAtMouse @0x00460D70` — the per-element entry + point; fired from mouse-hover dispatch. +- `UIElementManager::StartTooltip @0x0045DE90` and a second call site + `@0x00459700` — the manager-level owner that actually builds/positions + the tooltip popup element and starts its show/delay timer. +- Layout DID `0x21000041` — the authored tooltip popup LayoutDesc (not yet + imported/mounted by `LayoutImporter`/`RetailUiRuntime`). +- Element properties `P0x47`/`P0x48`/`P0x49`/`P0x4A`/`P0x4B` — the five + per-element tooltip-text/behavior properties `UIElement::OnSetAttribute` + reads (exact semantics per property still need re-derivation when this + issue is picked up — the investigation only confirmed the property IDs, + not their individual meanings). +- Measured **~253 authored elements client-wide** carry at least one of + those five properties (a scope comparable to #408's 1,083-element sweep, + though a different property family). +- User-facing config: `Misc_TooltipEnable`/`Misc_TooltipDelay` prefs (the + Options-panel-adjacent settings that gate whether tooltips show at all + and how long the hover dwell is before one appears). + +Fix direction, mirroring #408's own "own gate round" shape: (1) grep-named +first on all four decomp anchors above and re-derive the exact show/hide/ +position/delay state machine (`StartTooltipAtMouse` → `StartTooltip` → +popup lifecycle) before writing any pseudocode; (2) import/mount layout +`0x21000041` through the existing `LayoutImporter`/`RetailUiRuntime` +pipeline; (3) wire client-wide mouse-hover dispatch (likely through the +existing `InputDispatcher`/`UiRoot` hover-tracking, if any already exists, +or a new hover-timer owner otherwise) to read the five P0x47-P0x4B +properties per hovered element; (4) honor `Misc_TooltipEnable`/ +`Misc_TooltipDelay` from `RuntimeCharacterOptionsState`/ +`CharacterOptionTable` (Campaign OP's existing option-storage owner); (5) +a live-DAT sweep of the ~253 elements (same shape as #408's per-LayoutDesc +enumeration) before claiming full coverage, since a partial per-page +implementation would repeat the "accumulate a bigger partial table" +mistake #306 already named for a different subsystem; (6) its own +connected visual gate — hovering a representative sample across multiple +screens (chargen, main game UI, chat, Options) side-by-side with retail. + +## #408 — General importer-wide honor of dat property 0x3B (Invisible) is unshipped (1,083 elements client-wide) + +**Status:** OPEN +**Severity:** LOW-MEDIUM (cosmetic — extra/leaked elements render where retail hides them; no gameplay/wire impact) + +Found while fixing GF-13 (Campaign CC gate round 1, Batch A, 2026-08-16): +acdream's `LayoutImporter`/`DatWidgetFactory` never read dat property +`0x3B` (Invisible — `BoolBaseProperty`), which retail's +`UIElement::OnSetAttribute @0x00462d80` case 8 +(`GetPropertyName()-0x33==8`) honors on EVERY element via +`SetVisible(value==0)`. The blast-radius sweep this fix's investigation +ran found **1,083 elements client-wide** author `P0x3B=true` — far +beyond the two chargen-Summary GM labels (`0x10000403` +"Non-Admin"/`0x10000494` "Non-Envoy") the user actually reported. + +The fix (`fix(chargen): Campaign CC gate round 1 Batch A`) added the data +plumbing everywhere (`ElementInfo.Invisible`, read in +`ElementReader.ApplyCanonicalLegacyProjection`; `UiElement.AuthoredInvisible`, +set in `LayoutImporter.BuildWidget`) but deliberately does NOT act on it +in the shared importer path — only `CharacterCreationUiController` +(`HideAuthoredInvisibleElements`) walks its own mounted subtree and +hides what it finds, chargen-scoped only. Register row AP-230 records +the split. + +Honoring the flag client-wide (setting `UiElement.Visible = false` +directly in `LayoutImporter.BuildWidget` when `info.Invisible` is true, +or an equivalent central chokepoint) is straightforward, but 1,083 +elements is its own visual-regression surface: any one of them could be +an element some OTHER screen currently relies on being visible despite +authoring the flag (e.g. a state-conditional visibility toggle that +happens to leave `0x3B=true` on its default/direct state while a +controller separately manages `Visible` at runtime). This needs its own +sweep — dump the 1,083 ids grouped by owning LayoutDesc/screen, spot-check +a representative sample per screen against retail, then flip the +importer-wide switch with a dedicated visual gate — not a one-line +change folded into an unrelated fix. + +Fix direction: (1) enumerate the 1,083 ids per LayoutDesc (a live-DAT +probe test, similar to `SpewBoxLayoutDumpDiagnostic`); (2) for each +distinct screen/LayoutDesc, confirm honoring the flag doesn't hide +something the runtime currently manages visibility of dynamically at that +SAME element id (would double-drive `Visible`); (3) flip the honor in +`LayoutImporter.BuildWidget` (mirroring the chargen-scoped code path +already proven live) and delete `CharacterCreationUiController`'s own +narrow `HideAuthoredInvisibleElements`/AP-230 in the same commit; (4) run +a full-client visual matrix, not just chargen. + +## #407 — Windowed resolution offering starves on RDP/virtual displays (video-mode gating) + +**Status:** DONE (`e601a496`, 2026-08-16 — same gate round, user-directed immediate fix) +**Severity:** MEDIUM (windowed usability on remote/virtual displays) + +Found live during the CC gate over RDP: the Config Resolution dropdown +offered exactly two entries — `1920x1080` and the desktop's own +`2056x1290` — because the RDP virtual display's driver advertises only +those two video modes (measured via `EnumDisplaySettings`: the physical +2560x1440 monitor's mode list is not visible to the remote session at +all; the two secondary virtual displays expose only `800x600`). +`DisplayModeCatalog` (#391) honestly curates what the monitor +enumerates — the defect is the DESIGN conflation: the WINDOWED size +offering is gated on fullscreen-capable video modes, but a windowed +client needs no video mode — any size that fits the desktop is +displayable. On a physical monitor the conflation is invisible (rich +mode list); on RDP it collapses to nothing below 1920. + +Fix direction: split the offering by target state. The dropdown offers +(static modern ladder entries that fit the desktop) ∪ (curated hardware +modes), ascending; the windowed apply (a plain Size write) accepts any +offered entry ≤ desktop; the fullscreen apply keeps the hardware-catalog +validation + `GlfwDisplayModeSwitcher`'s monitor-mode-list hard guard +UNCHANGED (a fullscreen pick of a non-hardware mode refuses safely, +log-and-stay per #388 — the #392 apply-result seam is that family's +existing follow-up). #391's "an offered mode is by construction a +supported one" invariant narrows to the fullscreen half and must be +re-documented; register IA-22 (user-directed curation) gets the same +amendment. Immediate workaround (confirmed live): drag-resize the +windowed client — resize events rebuild the swapchain (#387) and the +retail UI rescales from its 800x600 authored canvas. + +## #406 — CLOSED: Launcher records a crashed client as `exited{code:0,reason:"graceful"}` + +**Status:** DONE (this commit, 2026-08-16) +**Severity:** MEDIUM (diagnosis-misleading, not data-loss) + +Found while diagnosing #405: the client process died with exit code +`0xE0434352` (.NET unhandled exception, stack on stderr), but the +launcher's session status stream recorded `{"e":"exited","code":0, +"reason":"graceful"}` — the exact opposite of what happened. + +Root cause was NOT in the launcher's process supervision (its own +OS-level exit-code read was always correct) — it was in the CLIENT's own +self-report. `GameWindow.Dispose()` (`src/AcDream.App/Rendering/GameWindow.cs`) +runs unconditionally via `Program.cs`'s `using var window = new +GameWindow(...)` even when invoked mid-unwind of an exception that +escaped `Run()`'s Silk.NET frame loop — the resource-shutdown transaction +itself can converge cleanly (nothing it tears down touches the crash), +so `CompleteShutdown` had no way to tell "normal `Run()` return" from "an +exception is propagating through me right now" and always wrote the +hardcoded `exited{code:0,reason:"graceful"}`. Fixed by latching +`_runFailure` in `Run()`'s existing `catch (Exception failure)` block +(right before the `throw;` that already existed for the +`_constructionCleanup.RetainFrom(failure)` ledger) and consulting it from +a new `ReportExited` method that is now the ONE call site for the +terminal status write: `exited{code:1,reason:"crashed"}` when a crash was +observed, `exited{code:0,reason:"graceful"}` on a real graceful +Dispose(), `exited{code:1,reason:"shutdown-incomplete"}` unchanged for a +non-crash teardown failure. `"crashed"` is a new value for the already- +free-text `reason` field (§LA1's `exited{code,reason}` vocabulary pins +the EVENT name, not an enum of `reason` strings — `StatusEventParser` +already round-trips any string there) so no wire-contract amendment was +needed. Pinned as a source-shape test (`GameWindowCrashStatusTests`) since +`GameWindow` cannot be constructed without a live GPU/window. **Precedence +(F15, gate round 1 closeout, 2026-08-16):** `ReportExited`'s `_runFailure` +check runs FIRST and returns immediately, so a crash ALWAYS wins over an +incomplete shutdown for the same session: if `Run()` observed an +exception AND the resource-shutdown transaction subsequently failed to +converge (`report.Status != Complete`), the reported reason is still +`"crashed"`, never `"shutdown-incomplete"`. The teardown failure itself is +not lost -- `Console.Error.WriteLine` still logs the blocked stage and +every cleanup failure right before `ReportExited` runs -- but the ONE +terminal status event a launcher/monitoring consumer reads only ever +carries one reason per session, and a crash is judged the more actionable +of the two. + +Sibling gap fixed in the same commit: the launcher previously discarded +the child's stdout/stderr entirely, which is why diagnosing this exact +crash required a manual console re-run. Added +`BoundedProcessOutputCapture` (`src/AcDream.Launcher.Core/Launching/`) — +a 2 MiB-capped, additive-only sink mirroring `SessionStatusWriter`'s +open-append-flush-close-per-write posture (a long-lived write handle is +NOT actually concurrently readable on Windows even with +`FileShare.Read` — confirmed by isolated repro) — wired into BOTH +`SystemChildProcess` (`ProcessStartInfo.RedirectStandardError` + +`ErrorDataReceived`; used on Linux for every child and on Windows for +graphical/non-console children, i.e. exactly this bug's own App/GUI +scenario) and `WindowsSystemChildProcess` (a real native pipe via a new +`CreateChildOutputPipe`, mirroring the existing stdin pipe in the +opposite direction, drained on a background pump thread; used on Windows +for console-capable children, i.e. Headless). The capture path is opt-in +via a new `LauncherProcessSpec.StderrLogPath` (null = behave exactly as +before) threaded through `SessionConfigComposer` → `client.err.log` +beside `status.jsonl` in the per-session directory → +`LauncherExecutableSet.CreatePlaySpec`/`CreateProbeSpec` → +`LauncherOrchestrator`. Real end-to-end tests +(`LauncherProcessSupervisorTests`) spawn an actual child via both code +paths and assert the captured file. + +## #405 — CLOSED: chargen/summary preview leases missing Transfer killed every retail-UI window load + +**Status:** DONE (`fix #405` commit, 2026-08-16 — Campaign CC gate round 1) +**Severity:** CRITICAL (client unusable via launcher/retail-UI path) + +`LivePresentationCompositionPhase.CompletePresentation`'s lease-transfer +ladder never gained `chargenPreviewLease?.Transfer()` (CC6b-MOUNT) nor +`summaryPreviewLease?.Transfer()` (CC5, faithfully duplicating the same +miss). Both resources rode into the published result beside the +paperdoll/appraisal siblings, but `CompositionAcquisitionScope.Complete()` +saw two acquired-unpublished leases and threw +`InvalidOperationException: Composition phase completed with unpublished +resources: chargen preview viewport, summary preview viewport` on EVERY +real window load with retail UI mounted — the client died ~1.7 s after +start, before connecting. Five review rounds read past it because no +automated suite executes the transfer ladder (it needs a live GPU +window; `LivePresentationCompositionTests` covers scope mechanics only) +and no graphical launch happened between CC6b-MOUNT's landing and the +user's gate. Follow-up test-coverage gap: a composition-level fake-GPU +harness that drives `ComposeCore` through `scope.Complete()` would have +caught this and remains unbuilt — weigh it against the E6 deterministic +suite patterns before CC's campaign close. Verified fixed by a live +launch: `started → connected → characterList`, graceful close. + +## #404 — ChargenSkillScoreResolver duplicates ChargenTableReader's own SkillTable read + +**Status:** OPEN (post-CC cleanup follow-up) +**Severity:** LOW +**Filed:** 2026-08-16 (Campaign CC CC5 re-review residual round, nit 3) +**Component:** `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs` +(`ChargenSkillScoreResolver` construction, `:670-672`), +`src/AcDream.Content/CharGen/ChargenTableReader.cs` (`:41`, `:61`) + +`ChargenSkillScoreResolver`'s constructor takes its OWN independent read of +the global SkillTable (portal.dat `0x0E000004`) at composition time +(`InteractionRetainedUiComposition.cs:670-672`, +`d.Dats.Get(0x0E000004u)`), beside `ChargenTableReader`'s +own already-established read of the SAME table +(`ChargenTableReader.cs:41` names the id, `:61` reads it) — which discards +the DAT's `SkillFormula` field entirely (`ChargenTableReader.Project` only +projects `TrainedCost`/`SpecializedCost` per skill into +`ChargenSkillCost`, never `SkillBase.Formula`). Two independent reads of +the same DAT file are harmless today (both are read-only, one-shot, under +the DAT lock) but are a duplicate-source-of-truth smell: if the two readers +ever diverge (a caching change, a future write path), nothing enforces they +stay in sync. + +**Fix direction:** project `SkillFormula` (and `MinLevel`, needed by +`RetailSkillFormula.CalculateChargenScore`'s gate) into `ChargenOptions` +alongside the existing `GlobalSkillCostsBySkillId` — `ChargenTableReader` +already walks every `SkillBase` in the table +(`ChargenTableReader.Project`'s `globalSkillCosts` loop) so adding the +formula/MinLevel costs no new DAT read, just a wider projection type. Then +`ChargenSkillScoreResolver` becomes pure arithmetic over `ChargenOptions` +it already receives from the caller, with no `SkillTable`/DAT dependency of +its own, and its constructor-time DAT read goes away entirely. + +**Acceptance:** one SkillTable read at composition time (through +`ChargenTableReader`), not two; `ChargenSkillScoreResolver` (or its +replacement) takes `ChargenOptions`/a projected formula table instead of a +raw `SkillTable`; existing `RetailSkillFormulaTests`/`ChargenTableReaderInstalledDatTests` +coverage still passes. + +## #403 — Consolidate RetailAnimationCyclePlayback into LiveEntityAnimationPresenter's legacy branch + +**Status:** OPEN (post-CC consolidation follow-up) +**Severity:** LOW +**Filed:** 2026-08-15 (Campaign CC slice CC6b-PRE review fix round, F5) +**Component:** `src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs`, +`src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs` + +`RetailAnimationCyclePlayback` (advance-with-wrap + lerp/slerp) is a Core, +pure, unit-tested extraction of the SAME algorithm +`LiveEntityAnimationPresenter.Present`'s legacy (no-`AnimationSequencer`) +branch already carries inline for NPC idle cycles +(`CurrFrame += legacyAdvanceSeconds * Framerate` with the same modulo wrap, +plus its own private `TryResolvePartFrame` doing the same frame-bracket +lerp/slerp). The chargen preview (`ChargenPreviewAnimator`) consumes the +new shared type; the two implementations were deliberately left +un-consolidated at CC6b-PRE — `LiveEntityAnimationPresenter` is live, +heavily-tested, in-flight production entity-rendering code with zero +relation to the preview-only feature that motivated the extraction, so +touching it was judged out of that slice's blast radius. + +That decision has no tracked owner. Someone should, in a dedicated pass +after Campaign CC closes: redirect `LiveEntityAnimationPresenter`'s inline +copy through `RetailAnimationCyclePlayback` (a behavior-preserving +mechanical swap — same formulas, same order of operations) and delete the +duplicate. Verify byte-identical output first (a differential test against +the pre-change behavior over a representative NPC idle set) before landing. + +**Acceptance:** one call site for the advance-with-wrap + lerp/slerp +algorithm; `LiveEntityAnimationPresenter`'s legacy branch calls +`RetailAnimationCyclePlayback` instead of reimplementing it; no behavior +change to any currently-animated NPC. + +## #402 — Flaky test: Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate + +**Status:** OPEN (flake, not a regression) +**Severity:** LOW (test-infra noise; no known production defect) +**Filed:** 2026-08-15 (Campaign CC slice CC4 review fix round, R2 — noticed +while running the full App.Tests suite repeatedly for the F1/R1 +FixedCanvasSize arbiter gate) +**Component:** `tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs` + +`Build_UsesTheSuppliedSharedReaderGate` fails intermittently in full-suite +runs (observed roughly 2 of 5 runs) but passes reliably when run in +isolation (`--filter FullyQualifiedName~Build_UsesTheSuppliedSharedReaderGate`). +The test was last touched at `82f8d4f8` (2026-07-25, Slice I7's parsed- +collision-graph removal) — unrelated to any Campaign CC/CC4 chargen work, +which never touches streaming/collision code. Symptom pattern (passes +isolated, flakes under full-suite parallelism) points at shared mutable +state or a timing assumption racing another test class rather than the +factory logic itself; not yet root-caused. + +**Fix direction:** re-run the full suite a few times to reproduce and +capture the failure's actual assertion/exception (not just "sometimes +red"), then check `LandblockBuildFactoryTests`'s fixture for anything +shared across test classes (static state, a shared reader/gate instance, +file-system paths) that a parallel xUnit collection could race. + +**Acceptance:** the flake is reproduced with a captured failure detail, +root-caused, and fixed (or the test is isolated into its own collection if +the root cause is unavoidable cross-test parallelism); full-suite runs stop +intermittently failing on this test. + +## #401 — RetailUi should default ON (opt-out), not per-path forced + +**Status:** OPEN (product-default decision) +**Severity:** MEDIUM (recurrence risk) +**Filed:** 2026-08-15 (Campaign LA gate-round-2 batch review, F2) +**Component:** `src/AcDream.App/RuntimeOptions.cs` + +`RetailUi` still parses opt-IN from `ACDREAM_RETAIL_UI` (default false), and +`6e1c0967` forces it true on exactly one call site (the session-config +launch path). Any other product entry point — including CLAUDE.md's +documented plain `dotnet run` dev launch — still boots world rendering with +zero interface, the same trap one caller later. The ImGui frontend is gone +(Campaign V), so `RetailUi == false` means "no UI at all"; the review +confirmed nothing legitimately needs that in a product or test path. + +**Fix direction:** invert the flag — retail UI on by default, +`ACDREAM_RETAIL_UI=0` as the dev opt-OUT — and delete the per-path forcing +in `RuntimeOptions.FromSessionConfig`. Sweep launch scripts/docs +(CLAUDE.md's launch command, test-script env listings) for stale +`ACDREAM_RETAIL_UI=1` mentions in the same change. Also pin the currently +untested "explicit `ACDREAM_RETAIL_UI=0` alongside a session config is +ignored" behavior — or make the inversion moot it. + +**Acceptance:** every launch path shows the retail UI unless explicitly +opted out; the forcing is gone; docs updated. + +## #400 — Character select: Credits button is ghosted; retail opens gmCreditsUI + +**Status:** OPEN (post-LA polish) +**Severity:** LOW +**Filed:** 2026-08-15 (Campaign LA gate round 2, char-select findings batch) +**Component:** `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` + +Retail's character-management screen routes the Credits button +(`0x100003A3`, listbox-base offset 6 in +`gmCharacterManagementUI::ListenToElementMessage @0x004ed5a0`) to +`QueueUIMode(0x10000005)` → `gmCreditsUI` (`Register @0x0047a69e`) — a +scrolling credits screen. acdream ghosts the button (visible, disabled, +no invented action — the same treatment as Create Character). Porting +`gmCreditsUI` is its own small screen (authored layout, scroll behavior, +return-to-select) and is deliberately out of Campaign LA's scope. + +**Acceptance:** Credits opens the ported retail credits screen and +returns to character select; button re-enabled. + +## #399 — Launcher: no test ever constructs MainWindow, so code-behind defects reach the user gate + +**Status:** DONE (this commit, Campaign LA UI-test slice) — closed via +`tests/AcDream.Launcher.Tests/MainWindowViewTests.cs`. +**Severity:** HIGH (process class: this gap let #398 — a crash on every +modal open/close — pass 14,012 green tests and reach the user gate) +**Filed:** 2026-08-15 (found while launching the launcher for the LA11 gate) +**Component:** tests/AcDream.Launcher.Tests + +`tests/AcDream.Launcher.Tests` is ViewModel-only — its csproj has no +Avalonia headless package, and no test instantiates `MainWindow` or any +view. `LauncherWindowViewModelTests` proved the modal state machine while +the code-behind that consumes it was never executed once, which is exactly +how #398's null `x:Name` fields survived every automated gate. + +**Fix landed.** Added `Avalonia.Headless.XUnit` 12.1.1 to the launcher test +project (its net10.0 dependency group targets **xunit v3**, so the project +migrated `xunit` 2.9.3 → `xunit.v3` 3.2.2 — a drop-in swap; all 54 +pre-existing `[Fact]`/`[Theory]`/`Assert.*` tests compiled and passed +unchanged, only two call sites needed `TestContext.Current.CancellationToken` +per the new `xUnit1051` analyzer). `TestAppBuilder` +(`tests/AcDream.Launcher.Tests/TestAppBuilder.cs`) wires +`[assembly: AvaloniaTestApplication]` to a headless `AppBuilder.Configure()` +so FluentTheme (declared in the real `App.axaml`) is live for every test. +`MainWindowViewTests.cs` adds 12 `[AvaloniaFact]`/`[AvaloniaTheory]` tests: +an explicit non-null check of every `x:Name` field the code-behind +dereferences, a reflection sweep over every `x:Name` in the markup (so a +future named control without a matching non-null field fails loudly), and +one open+close round trip per `ProfileEditorKind` (all seven, including +`Remove`) plus the first-run wizard and the update prompt — each pumping +`Dispatcher.UIThread.RunJobs()` so the `Dispatcher.UIThread.Post` callback +in `OnViewModelPropertyChanged`/`FocusActiveModal` actually executes, not +just gets queued. A dedicated test proves the `_focusBeforeModal != null` +restore branch (not just the `ProfilesTree.Focus()` fallback) also runs +clean, anchored on a real focusable button since `ProfilesTree` (a +`TreeView`) has `Focusable="False"` under FluentTheme — its own tab stops +are `TreeViewItem` rows, so the close-path assertions check "no exception +escaped" rather than "focus landed on ProfilesTree" (that would be a false +expectation, not the bug this issue is about). + +**Falsification (required evidence).** Reverting `MainWindow`'s constructor +to `AvaloniaXamlLoader.Load(this)` and rerunning: **12 failed / 0 passed** +— 10 tests throw `System.NullReferenceException` at +`AcDream.Launcher.MainWindow.FocusActiveModal` (propagating cleanly out of +`Dispatcher.UIThread.RunJobs()`, confirming dispatcher exceptions are not +swallowed), the 2 reflection tests fail on an explicit +"`x:Name 'ProfilesTree' was null after construction`" message. Restoring +`InitializeComponent()`: **12 passed / 0 failed**. Full launcher suite: +**66 passed / 0 failed** (Windows and native Ubuntu/WSL, both post-fix). +`tests/AcDream.Launcher.Core.Tests`: 317/317 unaffected. + +**CI.** `.github/workflows/headless-portability.yml`'s `portable-launcher` +job already runs `dotnet test tests/AcDream.Launcher.Tests/...` on both +`windows-latest` and `ubuntu-latest` with no display setup — no workflow +change was needed, since `Avalonia.Headless` requires no real windowing +system (confirmed directly: the new tests pass unmodified under WSL/native +Linux with no `DISPLAY` or Xvfb). + +**Acceptance (met):** a headless view test fails against the pre-#398 code +(`AvaloniaXamlLoader.Load`) and passes after, and runs in the portable +Windows+Ubuntu CI lane alongside the existing launcher tests. + +## #398 — Launcher: fatal startup/dispatcher exceptions are reported without a stack + +**Status:** DONE (`e1e94697`) +**Severity:** MODERATE (diagnosability) +**Filed:** 2026-08-15 · **Closed:** 2026-08-15 +**Component:** `src/AcDream.Launcher/Program.cs` + +`Program.Main`'s top-level guard printed only `ex.Message` before returning +74 — the `MainWindow` NullReferenceException fixed at `d54b8a78` surfaced +with no file, line, or frame, and diagnosis required temporarily editing +the guard and rebuilding. + +**Fix landed (`e1e94697`).** `TryWriteCrashReport` writes the full +exception chain plus non-identifying host facts (UTC, OS, RID, assembly +version) to `/crash-reports/launcher-crash-.log`; +stderr stays terse and names the path; the reporter itself never throws. +When option parsing is the failure, the caller's `--data-dir` is still +honored via a positional, validation-free read — the first implementation +fell back to the machine's real data root and broke LA11's process-local +roots during an isolated run (observed live, then fixed in the same +commit). Verified: forced startup failure writes the report inside the +isolated root with the full stack; the real root stays empty. + +**Redaction, stated exactly (deliberate narrowing of the filed +acceptance):** the report never serializes the command line, environment, +or process state, but exception TEXT may quote an option name or path. +The gate-round-1 review (F1) corrected the original by-construction claim: +the launcher DOES hold credentials (`ProfileEditorDialogViewModel`, +`AccountProfile.Password`, `StartRequest.Password`); the true invariant is +narrower — no code path interpolates a credential VALUE into an exception +message. That invariant is now PINNED by +`MainWindowViewTests.CrashReportNeverContainsAStoredPassword`: a real +STJ parse failure over a profiles document containing a known password, +corrupted after the credential so the parser consumed the value, must +produce a crash file with the stack and without the password. If that test +ever fails, this sink needs the status-stream's credential scanning. + +## #397 — Windows: LauncherProcessSupervisor.Stop has no reliable graceful-stop signal for a no-window console host + +**Status:** IN-PROGRESS — the isolated process-group implementation and real +Windows fixtures are complete; the LA11 connected acceptance row remains +required before closure. +**Severity:** MODERATE (a hard-killed `AcDream.Headless` leaves the ACE +account session stuck for several minutes — a documented project landmine; +see CLAUDE.md "Logout-before-reconnect") +**Filed:** 2026-08-14 (Campaign LA plan §LA3 review-fix round, finding F3) +**Component:** Launcher.Core / process supervision + +**Implementation checkpoint.** `LauncherProcessSupervisor.Stop` attempts +`ILauncherChildProcess.TryRequestGracefulStop` before `CloseMainWindow` and +the timeout/kill fallback. Linux retains its K4-proven targeted `SIGINT`. +On Windows, console-capable launcher specs now use a narrow no-shell +`CreateProcessW` seam with `CREATE_NEW_PROCESS_GROUP`, a suspended start, and +an explicit inherited-handle list that preserves only redirected stdin plus +stdout/stderr. A consoleless Avalonia parent briefly allocates and hides a +console for the creation transaction, detaches after the new group inherits +it, and later attaches only long enough to send +`GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, childProcessGroupId)`. Each such +child is therefore both the root of its own process group and, for the normal +Explorer-launched case, attached to its own console. Graphical children opt +out and retain the ordinary `Process`/`WM_CLOSE` path. + +Two real Windows fixture gates cover both a console parent and a consoleless +WinExe parent. They prove exact complex argv, redirected stdin, receipt of a +targeted CTRL_BREAK marker, exit code 0 before timeout, no supervisor `Kill`, +and a sibling process group that remains running until it receives its own +targeted break. Safe-handle cleanup, early-failure termination, and the +Linux SIGINT gate remain covered by the Launcher.Core suite. + +**Acceptance for closing this issue:** automated process-group and targeted- +signal coverage is complete. Keep the issue IN-PROGRESS until the LA11 live +connected row proves `AcDream.Headless` exits gracefully and ACE clears the +session immediately (not after the ~3-minute stale-session window) when +stopped through `LauncherProcessSupervisor.Stop` on Windows, matching the +Linux SIGINT behavior. + ## #396 — Configure Keyboard: no capture-instruction dialog on a mapping-button click **Status:** ROOT-CAUSED + FIXED — pending the user's visual re-gate of the @@ -1382,29 +1972,25 @@ controllers read instead of the main window's private field. ## #366 — Chat window's new-unseen-text indicator (0x1000048C) imports but is never independently wired -**Status:** OPEN — filed 2026-08-10, Campaign CH slice CH6a. The retail main -chat window authors a 16×16 "new unseen text" indicator button -(`0x1000048C`, base `0x10000527`/`0x21000040`) as a CHILD of the transcript -text element `0x10000011` (position `(0,57)` relative to the transcript, -i.e. bottom-left of the transcript pane), confirmed in the `0x2100006F` -LayoutDesc dump. `UiText.ConsumesDatChildren` is `true` (Type-12 behavioral -widgets reproduce their dat sub-elements procedurally per -`DatWidgetFactory`'s own doc comment), so `LayoutImporter.BuildWidget` -never builds `0x1000048C` as a separate widget — it is silently swallowed, -same as under the wrong `0x21000006` layout before it (not a CH6a -regression). No controller anywhere binds or drives its visible state. -**Not in CH6a's scope** (transcript/input/scrollbar/1-4-buttons only) and -not obviously CH6b/CH6c's either — files here as a standalone gap. Fix -shape: either give `UiText` an opt-in mechanism to keep specific named -non-Type-3 children (mirroring `UiMeter`'s existing text-overlay carve-out -in `DatWidgetFactory.BuildWidget`), or handle `0x1000048C` as a special -case the same way. Needs research first: what triggers retail's "new text" -indicator (unread-since-scroll-position?) and what it visually does on -click — not decoded by CH6a. +**Status:** OPEN, NARROWED 2026-08-16 at Campaign CC gate round 1 Batch C +Commit 2 — the BUILD half of this issue's own "fix shape" recommendation is +now DONE. `LayoutImporter.BuildWidget` gained a `UiText`/`UiField` +media-bearing-child carve-out (mirroring `UiMeter`'s own text-overlay +carve-out, EXACTLY the shape this issue proposed) as part of a chargen +description-box fix; the client-wide blast-radius sweep that fix's own +tests run +(`LayoutImporterMediaBearingChildSweepTests.MediaBearingChildSweep_EnumeratesEveryAffectedType12Element`) +independently re-confirmed `0x1000048C` under `0x10000011` in layout +`0x2100006F` as one of the affected elements — it now builds as a real +widget instead of being silently swallowed. **Still open:** no controller +binds or drives its visible state (STILL the original ask — what triggers +retail's "new text" indicator, and what it does on click, remains +un-researched); this issue stays open for that behavioral half. -**Where:** `src/AcDream.App/UI/Layout/ChatWindowController.cs`; -`src/AcDream.App/UI/Layout/LayoutImporter.cs` -(`BuildWidget`/`ConsumesDatChildren` handling); `src/AcDream.App/UI/UiText.cs`. +**Where:** `src/AcDream.App/UI/Layout/ChatWindowController.cs` (behavior, +still missing); `src/AcDream.App/UI/Layout/LayoutImporter.cs` +(`BuildWidget`'s new `UiText or UiField` carve-out — CLOSED the build half); +`src/AcDream.App/UI/UiText.cs`. ## #367 — ChatCommandRouter's local-presentation fallbacks type-0x1A text still lands in the chat scroll, never the SpewBox diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 2eae6a17..184e26e4 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -92,7 +92,7 @@ stack. Full history and the corrected contract live in │ LayoutDesc/DAT → UiRoot retained widgets + controllers │ ├─────────────────────────────────────────────────────────────┤ │ SHARED CONTRACTS │ -│ ViewModels, commands, input actions, state/event services │ +│ ViewModels, input actions, state/event and command seams │ │ ► one model and mutation path, one presentation projection │ ├─────────────────────────────────────────────────────────────┤ │ Game state + events (unchanged) │ @@ -100,27 +100,44 @@ stack. Full history and the corrected contract live in └─────────────────────────────────────────────────────────────┘ ``` -`AcDream.UI.Abstractions` — the `IPanel`/`IPanelRenderer` contract, the -ViewModels and the commands — **survives intact**. It was always +`AcDream.UI.Abstractions` — the `IPanel`/`IPanelRenderer` contract and the +ViewModels — **survives intact**. It was always backend-agnostic, which is exactly what Code Structure Rule 3 was written to protect, and it is what a future developer-panel host would bind to. Only the ImGui *backend* was deleted. `ACDREAM_DEVTOOLS=1` still selects Vulkan's debug-utils extensions and now logs that the developer UI is gone; replacing it is issue **#258**, deliberately unscheduled. -`AcDream.UI.Abstractions` owns backend-neutral ViewModels, commands, input, -and the `IPanel`/`IPanelRenderer` devtools contract. `AcDream.App/UI` owns the +`AcDream.UI.Abstractions` owns backend-neutral ViewModels, input, and the +`IPanel`/`IPanelRenderer` devtools contract. `AcDream.App/UI` owns the retained gameplay tree, LayoutDesc importer, window runtime, and panel controllers. Neither presentation stack owns independent game-state truth. -Chat submission follows the same rule: both presentation stacks enter the -shared `ChatCommandRouter`, which emits distinct backend-neutral intents for a -retail client command (`ExecuteClientCommandCmd`), an ACE-owned command -(`SendServerCommandCmd`), or ordinary chat (`SendChatCmd`). App-layer -handlers and controllers translate those intents to `WorldSession`; panels -never inspect or construct wire messages. +Chat submission follows the same rule: `AcDream.Runtime/Chat` owns the shared +parser, retail command/channel/help catalogs, `ChatCommandRouter`, command bus, +and its four backend-neutral records (`ExecuteClientCommandCmd`, +`SendServerCommandCmd`, `SendChatCmd`, and `SendRawChannelCmd`). Its only +presentation callback is the four-member `IChatCommandFeedback`; retained +`ChatVM` implements that seam. Both App and Headless bind the same +`LiveChatCommandRoute` to the active `WorldSession` send delegates and exact +`RuntimeCommunicationState`/`RuntimeCharacterState` children. Panels never +inspect or construct wire messages, and Runtime has no UI or App dependency. +Configured login commands enter that identical parser/router only after the +generation's `enteredWorld` edge, in order, once per generation. The shared +generation-aware sequence cancels on replacement, applies the configured +inter-command delay, and reports each isolated failure without aborting the +session or plugin lifetime. Plugins register retained gameplay markup through the BCL-only `AcDream.Plugin.Abstractions.IUiRegistry`; they do not import App or -presentation assemblies. Core `SelectionState` is the sole selected-object owner for world, +presentation assemblies. `IPluginHost.HasUi` is the explicit capability edge: +the graphical host supplies its retained registry, while no-window hosts +return `false` and the BCL-only `NoOpUiRegistry`, which retains no plugin +binding. Both hosts use Core's session-scoped discovery/lifetime orchestrator +and the same config allow-list semantics (absent loads all; explicit empty +loads none). The headless adapter projects entity snapshots on demand from the +canonical Runtime view, subscribes to Runtime's ordered events, and borrows the +exact Runtime selection owner; it does not mirror gameplay state. + +Core `SelectionState` is the sole selected-object owner for world, radar, inventory, paperdoll, toolbar, use/examine consumers, and plugins; `IPluginHost.Selection` exposes that same state and retail-style old/new callback. Temporary pointer modes are separate App orchestration in `InteractionState` and @@ -148,6 +165,16 @@ window registration, plugin mounts, cursor feedback, layout persistence, and the retained tick/draw/restore/dispose paths. Panel-specific construction must not move back into `GameWindow.OnLoad`. +The graphical no-selector launch projects Runtime's sole +`RuntimeCharacterSelectionState` through the retained character-management root +resolved from DAT enum table 5 (`0x10000005` -> `0x21000004`, selected root +`0x1000039A`). App borrows the view and routes generation-capturing typed +commands; it owns no roster, highlight, operation, error, or lifecycle mirror. +The authored screen is a flat ListBox and buttons, with the shared retail dialog +catalog for confirmation, wait, and error presentation. It contains no viewport +or character preview. Explicit-selector graphical launches and no-window hosts +do not mount this presentation. + Magic follows the same boundary. Core `Spellbook` is the one learned/favorite/ desired/enchantment state projection; Core.Net owns exact manifest and live message parsing; Runtime `RuntimeActionState.SpellCast` owns validated cast @@ -174,6 +201,9 @@ parallel window-lifecycle map. ``` src/ AcDream.Core/ Layer 2-4: no Vulkan, no Silk.NET, pure logic + Plugins/ + PluginSession.cs -> shared per-host allow-list, failure isolation, + status outcome, and collectible ALC lifetime Physics/ PhysicsBody.cs -> body state / integration foundation (done) CollisionPrimitives.cs -> retail primitive helpers (partial, active) @@ -219,6 +249,9 @@ src/ generation + teardown Session/ -> J2 canonical session lifetime, ordered inbound routing + retryable teardown + RuntimeCharacterSelectionState.cs -> sole generation-scoped pre-world + roster/highlight/delete/restore/error owner; + borrowed view + ordered deltas + typed commands Entities/ RuntimeEntityDirectory.cs -> sole GUID/incarnation/local-ID authority RuntimeEntityRecord.cs -> presentation-free accepted entity state @@ -236,6 +269,9 @@ src/ RuntimeInitialCreateContinuationExecutor.cs -> retry-idempotent adoption + retail Create tail + strict-order FIFO/replay execution over the residence + Chat/ -> LA6 parser/router/catalog and four command + intents; shared live route + generation-scoped + configured-login sequence for both hosts Gameplay/ RuntimeCommunicationState.cs -> one chat/social owner + ordered stream RuntimeInventoryState.cs -> exact object-table borrower + inventory @@ -266,19 +302,83 @@ src/ World/ RuntimeWorldEnvironmentState.cs -> canonical calendar/time/weather owner RuntimeWorldTransitState.cs -> canonical reveal generation/readiness owner - Platform/ - ApplicationPathSet.cs -> shared BCL-only XDG/Windows config, data, - cache, plugin, screenshot, and diagnostic paths RuntimeGenerationReset.cs -> one retryable canonical-generation reset -> Slice J complete; graphical and no-window hosts share one GameRuntime - -> may reference Core, Core.Net, Content, and Plugin.Abstractions only + -> may reference Core, Core.Net, Content, Plugin.Abstractions, and + Platform only -> must never reference App, UI, Silk.NET, OpenAL, or Arch + AcDream.Platform/ BCL-only portable path contract (Campaign LA LA0) + ApplicationPathSet.cs -> shared XDG/Windows config, data, cache, + plugin, screenshot, and diagnostic paths + BakePublicationGuardPaths.cs + -> shared launcher/Bake environment nonce and + adjacent publication lock/token naming contract + -> zero project/package references (guarded by + tests/AcDream.Platform.Tests/PlatformDependencyBoundaryTests.cs); + Runtime and App reference it directly; Headless reaches it + transitively through Runtime (K0 guard: Headless declares exactly + one project reference) + + AcDream.Launcher.Core/ BCL-only launcher state/orchestration owner + Profiles/ -> sole credential/profile document + CRUD owner + Launching/ -> config composition and supervised process seams; + Windows console hosts are no-shell, redirected- + stdin process-group leaders receiving targeted + CTRL_BREAK, while Linux hosts receive SIGINT + Status/ -> incremental host-status parsing/tailing + Orchestration/ -> immutable UI snapshots, typed actions, + capability gates, and running-session lifetime + 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 + OS-handle lease serializes recovery/install per + DataDirectory; a second OS-held publication + lock plus durable per-transaction nonce makes + late orphan Bake children irrevocably stale + before recovery, while already-authorized + promotion completes before recovery; only exact + adjacent + `..acdream-bake..tmp` files are + transaction-owned crash residue + Updates/ -> pinned GitHub manifest + strict SemVer/RID + authority, bounded verified streaming download, + hardened ZIP extraction, immutable + `app//` installs, atomic `current.json` + activation/rollback, and durable next-start + launcher self-update journal; one OS-handle + shared-session/exclusive-update barrier spans + every launcher process + -> references Platform only; no Avalonia or game-host dependency + + AcDream.Launcher/ Avalonia 12 Windows/Linux desktop shell + Startup/Program -> one immutable process-local option graph before + owner construction; config/data/cache require + three absolute normalized roots and one exact + `ApplicationPathSet` reaches profiles, installer, + versions/updater, sessions, cache, orchestration + -> manifest override reaches only update composition, + 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 + nonfatal startup/manual update state, actions, + progress, cancellation, rollback, and errors + -> references Launcher.Core only (Platform transitively); it never owns + a second profile, process, status, or credential state graph + -> every per-RID publish composes the separately published self-contained + `acdream-bake` executable beside the launcher without a project edge + -> Linux launcher/probe/headless flows remain portable; graphical-client + actions are explicitly disabled until Modern Runtime Slice L resumes + AcDream.Headless/ Linux/Windows no-window production host Program.cs -> CLI entry only Configuration/ -> strict versioned process/session config Credentials/ -> redacted env/stdin/owner-only-file providers Hosting/ -> one GameRuntime/session/lease/policy lifetime + Plugins/ -> no-window IPluginHost borrowing Runtime/Core; + BCL no-op UI and per-session plugin lifetime Policies/ -> typed Runtime-view/command consumers -> references Runtime only; no presentation/backend package -> Slice K complete: portable single/multi-session production host, @@ -289,6 +389,7 @@ src/ AcDream.Plugin.Abstractions/ Layer 5: plugin interfaces IAcDreamPlugin.cs -> done IPluginHost.cs -> done + IUiRegistry.cs -> capability-aware retained/no-op UI contract IGameState.cs -> done IEvents.cs -> done ISelectionService.cs -> done @@ -343,6 +444,7 @@ src/ PlayerMovementController.cs -> active movement driver Plugins/ AppPluginHost.cs -> done + GraphicalPluginSession.cs -> thin shared-session/root/status adapter ``` The 4B2 production SetPosition routes and shared local-controller body remain diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index a56b00b8..e92876b8 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -120,6 +120,14 @@ ViewModel or command had to change, because none of them had ever imported writes against `IPanelRenderer`; a renderer implementation translates those calls at runtime. Plugin-facing UI follows the same rule. +The shared chat parser/router/catalog and its four command intents live in +`AcDream.Runtime/Chat`, not in a panel or App. `AcDream.UI.Abstractions` +references Runtime so retained `ChatVM` can implement the narrow +`IChatCommandFeedback` seam and its existing panel input can call the shared +router. That dependency does not permit panels to import App, windowing, +rendering, audio, or another presentation backend; Runtime itself remains +presentation-independent and its dependency guards enforce that boundary. + **Status:** there is currently no `IPanelRenderer` implementation in the tree — the ImGui one went with V11 and the replacement is issue **#258**. The contract is kept rather than deleted precisely because this rule proved its worth; a new @@ -467,7 +475,7 @@ useful ordering seam, but its ownership status is **partial**. | Area | Status | Current truth | |---|---|---| | Startup options | **Complete** | `RuntimeOptions` owns startup configuration (`eda936dc`). Remaining direct environment reads are legacy runtime diagnostics, not startup configuration. | -| Network session | **Complete Runtime ownership** | `RuntimeLiveSessionController` owns the sole `WorldSession` generation and resolve/create/Connect/selection/EnterWorld/Tick/stop/reconnect/disposal transaction. Runtime route owners preserve exact inbound/outbound ordering and retryable teardown. App supplies immutable options, graphical/domain callbacks, and one borrowed inertable UI command projection—no mirrored session or reset plan (`75930787`). | +| Network session | **Complete Runtime ownership** | `LiveSessionController` owns the sole `WorldSession` generation and resolve/create/Connect/pre-world selection/EnterWorld/Tick/stop/reconnect/disposal transaction. Its `RuntimeCharacterSelectionState` owns the full active roster (including greyed entries and retained wire slots), highlight, delete confirmation, restore/delete/error state, generation/lifecycle, borrowed view, ordered deltas, and typed commands. A selector-free graphical launch pauses on that owner; explicit and headless selection retain the established fallback. Runtime route owners preserve exact inbound/outbound ordering and retryable teardown. App supplies immutable options, graphical/domain callbacks, and borrowed projections—no mirrored session, selection state, or reset plan. | | World environment | **J6.1 complete Runtime ownership** | `RuntimeWorldEnvironmentState` owns the instance-scoped Dereth calendar, synchronized clock, weather progression/state, selected day group, AdminEnvirons state, and typed debug overrides. App converts immutable DAT sky definitions once and projects the borrowed Runtime snapshot into rendering; no process-global Region origin or second App clock/weather owner remains (`902076c0`). TS-54/TS-55 register the remaining centered UI sound and full fog/ambient/radar behavior gaps. | | Live identity/lifetime | **J3 complete** | `RuntimeEntityObjectLifetime` owns the sole `RuntimeEntityDirectory`, live `ClientObjectTable`, direct views, and ordered entity/object stream. The directory owns canonical GUID/incarnation/local-ID identity, accepted snapshots/timestamps, parent state, operation versions, and tombstones. `LiveEntityProjectionStore` owns App graphical sidecars by exact `RuntimeEntityKey`; hydration, presentation components, `GpuWorldState` residence/visibility, and retryable teardown preserve that key without another authority. Exact receipts precede fallible callbacks, re-entrant commits drain synchronously in sequence, and stable reset/disposal must converge the complete ledger to zero (`f46ddb5c`, `420e5eea`, `e937cc36`, `5ef8b537`, `ce3ac310`, `119b7c11`). | | Inbound/object-frame order | **Complete App orchestration** | `UpdateFrameOrchestrator` owns the complete typed host phase graph; `RetailInboundEventDispatcher`, `RetailLiveFrameCoordinator`, `LiveObjectFrameController`, `LiveSpatialPresentationReconciler`, streaming/input/teleport/player-mode/camera owners preserve the accepted order. `GameWindow.OnUpdate` is one profiler-scoped handoff (`e91f3102`). | diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index fc668ffe..dbe19519 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -59,11 +59,11 @@ accepted-divergence entries (#96, #49, #50). | IA-19 | Automatic combat acquisition is narrowed to attackable non-player monsters. Retail `AutoTarget` falls back to `SelectNext(SELECTION_TYPE_COMPASS_ITEM)`, whose combat filter can also admit attackable enemy players in compatible PK states. | `src/AcDream.Core/Combat/CombatTargetPolicy.cs`; consumers `src/AcDream.App/Interaction/WorldSelectionQuery.cs` (`IsHostileMonster`/`FindClosestHostileMonster`) and `SelectionInteractionController.cs` (`SelectClosestCombatTarget`). This row is auto-acquisition-only: as of #298, explicit-target admission and the combat camera route through the separate, retail-exact `WorldSelectionQuery.IsAttackableTarget` (`ObjectIsAttackable`-backed) instead, so a compatible-PK player is a valid manual attack/camera target — do not assume one predicate still serves both concerns. | Explicit product direction: Auto Target must never select NPCs, players, pets, or other objects; manual player-selection commands remain available | In PK play, Auto Target will not acquire an otherwise valid hostile player as retail would; the player must be selected manually | `ClientCombatSystem::AutoTarget @ 0x0056BC80`; `CPlayerSystem::SelectNext @ 0x0055F9A0`; `ClientCombatSystem::ObjectIsAttackable @ 0x0056A600` | | IA-20 | The basic combat bar keeps dark-red media `0x0600715E` visible as the centered middle baseline. Retail skill-gates field `0x100005EF` to trained Recklessness; the separate bright child remains faithful live `SetPowerbarLevel` feedback from the absolute left edge. | `src/AcDream.App/UI/UiScrollbar.cs`; child-policy extraction in `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` | Explicit connected visual direction: the dark middle track remains present behind live attack charge; the exact skill-gated treatment remains tracked by AP-112 | Untrained characters retain the dark-red baseline where retail may leave only the gray track; trained/untrained Recklessness presentation is not distinguishable | `gmCombatUI::RecvNotice_SetPowerbarLevel @ 0x004CC0E0`; `gmCombatUI::ListenToElementMessage @ 0x004CC430`; LayoutDesc `0x21000073` | | IA-21 | When ACE sends player BoolProperty `68` (`SpellComponentsRequired`) false, acdream presents the retail scarab/prismatic-taper formula even without a directly carried school focus. With component enforcement enabled, retail's exact focus/infusion versus account-customized selection remains intact. | `src/AcDream.App/Spells/SpellComponentRequirementService.cs` | A component-disabled server has no actionable legacy recipe; explicit product direction is that this client/server mode uses the modern scarab/taper component presentation | A custom server could expect retail's legacy recipe to remain visible even though casting consumes no components | `ClientMagicSystem::AreSpellComponentsRequired @ 0x00567B90`; `ClientMagicSystem::GetAppropriateSpellFormula @ 0x00567D50`; `CSpellBase::InqScarabOnlyFormula @ 0x00597050` | -| IA-22 | **Filed 2026-08-13 (#391, user-directed: "we should only support modern resolutions. Not any old format").** The Config Resolution dropdown offers a CURATED list — the monitor's real mode enumeration filtered to modern widescreen families (16:9/16:10/21:9/32:9, ≥1280 wide, fitting the desktop; `DisplayModeCatalog.Curate`) — and its Defaults value is the desktop's own mode. Retail offered the adapter's complete enumeration including 4:3 legacy modes and authored `800x600` as the row default (`gmConfigUI::InitOptions SetDefaultValue(0x03200258)`; `gmClient::Init @0x004047af` `Device::ForceDisplayResolution(1, 0x320, 0x258)`). | `src/AcDream.App/Rendering/DisplayModeCatalog.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (Resolution row); fixture fallback `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs` (`AvailableResolutions`, 800x600 removed) | Explicit product direction; the curated list is also the fullscreen mode-switch validation source (#376/#388), so an offered mode is supported by construction — "Graphics mode not supported" crashes become unreachable from the dropdown. | A user wanting a genuine legacy 4:3 mode cannot pick it; retail-parity comparisons of the Config tab's list/default will show the deviation. | decomp sites in the Divergence column; ISSUES #391 | +| IA-22 | **Filed 2026-08-13 (#391, user-directed: "we should only support modern resolutions. Not any old format").** The Config Resolution dropdown offers a CURATED list — the monitor's real mode enumeration filtered to modern widescreen families (16:9/16:10/21:9/32:9, ≥1280 wide, fitting the desktop; `DisplayModeCatalog.Curate`) — and its Defaults value is the desktop's own mode. Retail offered the adapter's complete enumeration including 4:3 legacy modes and authored `800x600` as the row default (`gmConfigUI::InitOptions SetDefaultValue(0x03200258)`; `gmClient::Init @0x004047af` `Device::ForceDisplayResolution(1, 0x320, 0x258)`). | `src/AcDream.App/Rendering/DisplayModeCatalog.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (Resolution row); fixture fallback `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs` (`AvailableResolutions`, 800x600 removed) | Explicit product direction. **Amended 2026-08-16 (#407, Campaign CC gate round 1):** the dropdown now offers `DisplayModeCatalog.WindowedResolutions` — the curated hardware modes UNIONed with the static modern-ladder sizes that fit the desktop — because a WINDOWED pick is a plain Size write needing no video mode, and remote/RDP virtual displays advertise almost no modes (the live RDP display exposed only 1920x1080 + the 2056x1290 desktop, starving the dropdown). The original "an offered mode is supported by construction" invariant now holds for the FULLSCREEN half only: the fullscreen apply still validates against the hardware `Resolutions` list plus `GlfwDisplayModeSwitcher`'s monitor-mode-list hard guard, so a fullscreen pick of a windowed-only entry refuses safely (log-and-stay, #388; the #392 apply-result seam is that family's open follow-up) — "Graphics mode not supported" crashes remain unreachable from the dropdown. | A user wanting a genuine legacy 4:3 mode cannot pick it; retail-parity comparisons of the Config tab's list/default will show the deviation. | decomp sites in the Divergence column; ISSUES #391 | --- -## 2. Adaptation (AD) — 73 active rows (AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) +## 2. Adaptation (AD) — 81 active rows (AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -151,7 +151,7 @@ readiness/requeue adaptation. See | AD-40 | The fsf `Stationary*` transient-bit encode (fsf→0x10/0x20/0x40) lives in the Core resolve writeback (`PhysicsEngine.ResolveWithTransition`), co-located with the fsf computation; retail encodes it in `handle_all_collisions` (pc:282737-758). Also: `PhysicsBody.CachedVelocity` is computed at the player chokepoint but not yet consumed — outbound wire velocity still uses the existing `get_state_velocity` path, not retail's cached_velocity source (#182 rebuild, 2026-07-07) | `src/AcDream.Core/Physics/PhysicsEngine.cs` (writeback); `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (`CachedVelocity`) | Encoding in the writeback keeps the seed→ladder→writeback→seed round-trip self-contained in Core (testable without the App loop); the bit values + timing are identical to retail's (set after fsf is final, before the next resolve). CachedVelocity is faithful to carry now; routing the wire through it is a separate, unmeasured change | If a future consumer reads the Stationary* bits expecting retail's handle_all_collisions to have set them (it doesn't run in Core), the Core writeback is the source of truth; a wire-reporting change that assumes CachedVelocity is live would send the wrong velocity until it's wired | `handle_all_collisions` bit encode pc:282737-758; `get_velocity` 0x005113c0 (cached_velocity reader) | | AD-41 | The `candidateMoved` gate (retail UpdateObjectInternal pc:283657 `candidate != m_position`) suppresses the WHOLE SetPositionInternal-shaped commit (contact/walkable flags, HitGround/LeaveGround, `handle_all_collisions`, `cached_velocity`) on a no-move frame — narrowed 2026-07-30 (#265 bounce rework) from "only handle_all_collisions"; acdream still runs `ResolveWithTransition` (zero-distance) for cell/contact tracking, where retail skips the whole transition (#182 rebuild, 2026-07-07) | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (`candidateMoved` guard) | The load-bearing effect is not re-zeroing the gravity velocity that rebuilds after a stuck-fall bleed; the zero-distance resolve is a near-no-op (numSteps 0 → the zero-step early return, no ValidateTransition, contact plane persists via the writeback), so running it is harmless while keeping acdream's per-frame cell/membership refresh | If the zero-distance resolve ever gains a side effect on a no-move frame (a contact-plane clear, an fsf change), it would diverge from retail's skip — a no-move frame must stay a near-no-op | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 pc:283657 (candidate-moved gate) | | AD-43 | A malformed/custom PhysicsScript `CallPES` cycle whose script timeline never advances is rejected with a diagnostic; retail's linked scheduler would continue draining that zero-time tail indefinitely | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs` (timeline-progress ancestry guard) | Prevents corrupt DAT content from hanging the single update/render thread. Installed-DAT audit plus conformance tests prove the real rolling-weather cycles advance 2.8 seconds per edge and continue unchanged; only a no-progress strongly connected cycle is rejected | A custom DAT that deliberately relies on an infinite zero-time loop observes a rejected play instead of freezing the client | `ScriptManager::AddScriptInternal` 0x0051B310; `ScriptManager::UpdateScripts` 0x0051B480; `CPhysicsObj::CallPES` 0x00511AF0 | -| AD-44 | acdream has no retained character-management screen: startup deterministically selects the first active, non-greyed CharacterList identity, and native-window close performs retail's complete character-logoff handshake plus transport disconnect before exiting instead of returning to character selection. One active `ReceiverData` equivalent means `ClientNet::LogOffServer`'s per-receiver loop sends one header. | `src/AcDream.Core.Net/Messages/CharacterList.cs` (`TrySelectFirstAvailable`); `src/AcDream.App/Rendering/GameWindow.cs` (live-session bootstrap, moving to `LiveSessionController` in Slice 3); `src/AcDream.Core.Net/WorldSession.cs` (`SelectCharacterForEnterWorld`, `Dispose`); `src/AcDream.Core.Net/Packets/TransportDisconnect.cs` | This preserves unattended startup and immediate ACE endpoint release while validating that the chosen identity is active/non-greyed and using the server's canonical account. A future retained character-management owner is separate UI/session work. | An account with multiple playable characters enters the first wire-order identity without retail's explicit choice. An eventual in-client "log off character" action cannot reuse the process-exit path; it must retain the authenticated socket after server `0xF653` and return to character management. | `gmCharacterManagementUI::SelectCharacter @ 0x004EC160`; `gmCharacterManagementUI::EnterGame @ 0x004ED440`; `gmCharGenMainUI::Update @ 0x004E8460`; `Proto_UI::LogOffCharacter @ 0x00546A20`; `CPlayerSystem::RequestLogOff @ 0x00562DD0`; `CPlayerSystem::ExecuteLogOff @ 0x0055D780`; `ClientNet::LogOffServer @ 0x00543EF0`; `SharedNet::SendOptionalHeader @ 0x00543160` | +| AD-44 | **NARROWED 2026-08-15 at Campaign LA gate round 2 (staleness caught while filing AD-99) — the opening clause was WRONG as of this session: Campaign LA's LA7/LA8 slices (landed in earlier commits on this branch) shipped a real retained `gmCharacterManagementUI`-authored character-select screen (`CharacterManagementUiController`, `RuntimeCharacterSelectionState`), and no register row was updated when they did.** What remains true: `TrySelectFirstAvailable` still deterministically picks the first active, non-greyed identity, but ONLY for headless/no-selector sessions and probe connects (LA7's no-selector flow) — a graphical session without a character selector now stops at the retained selection screen instead of auto-entering. Native-window close still performs retail's complete character-logoff handshake plus transport disconnect instead of returning to character selection; there remains no in-client path from in-world back to a live character-select screen (AD-99 documents the adjacent Exit-button gap: the screen's OWN Exit button now exists and confirms, but also closes the client rather than returning to selection). One active `ReceiverData` equivalent means `ClientNet::LogOffServer`'s per-receiver loop sends one header. | `src/AcDream.Core.Net/Messages/CharacterList.cs` (`TrySelectFirstAvailable`); `src/AcDream.Runtime/Session/LiveSessionController.cs` (`StartCore`'s `AwaitCharacterSelection` branch); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (the retained screen); `src/AcDream.Core.Net/WorldSession.cs` (`Dispose`); `src/AcDream.Core.Net/Packets/TransportDisconnect.cs` | Headless/probe sessions still need unattended selection (no UI to select from) — the deterministic fallback remains correct THERE. A full in-client "log off character, return to selection" flow is separate session/wire work no slice has scoped yet. | A headless/probe account with multiple playable characters still enters the first wire-order identity without an explicit choice (by design — no UI exists in that host). An eventual in-client "log off character" action still cannot reuse the process-exit path; it must retain the authenticated socket after server `0xF653` and return to character management — the graphical screen exists now, but nothing feeds it from an in-world state. | `gmCharacterManagementUI::SelectCharacter @ 0x004EC160`; `gmCharacterManagementUI::EnterGame @ 0x004ED440`; `gmCharGenMainUI::Update @ 0x004E8460`; `Proto_UI::LogOffCharacter @ 0x00546A20`; `CPlayerSystem::RequestLogOff @ 0x00562DD0`; `CPlayerSystem::ExecuteLogOff @ 0x0055D780`; `ClientNet::LogOffServer @ 0x00543EF0`; `SharedNet::SendOptionalHeader @ 0x00543160` | | AD-45 | App teardown can overlap a newer `INSTANCE_TS` record after retiring the old active identity. `TargetManager` therefore retains the exact target host and each `TargettedVoyeurInfo` retains the exact watcher host; unsubscribe, Sticky live-target reads, inbound sender validation, and ExitWorld delivery compare/use those pointer-like tokens rather than resolving a reused GUID. Retail stores only GUIDs because `DeleteObject` finishes `exit_world`/`leave_world` while the retiring `CPhysicsObj` remains the sole object-table entry. | `src/AcDream.Core/Physics/Motion/TargetManager.cs`; `StickyManager.cs`; `TargettedVoyeurInfo.cs`; `IPhysicsObjHost` exact relationship seams | This preserves retail's effective object-pointer identity while allowing App resource teardown to fail and retry without blocking an accepted newer server generation. Ordinary `GetObjectA` remains active-record-only, so tombstones cannot accept new relationships. | If any target/voyeur path bypasses the exact token, retrying an old teardown can remove or notify a newer same-GUID relationship, or Sticky can steer toward the replacement; retained tokens also keep the small manager graph alive until teardown converges. | `CPhysicsObj::exit_world @ 0x00514E60`; `CObjectMaint::DeleteObject(CPhysicsObj*) @ 0x00508460`; `ACCObjectMaint::DeleteObject(uint) @ 0x005576F0`; `TargetManager::SetTarget @ 0x0051AC30`; `ClearTarget @ 0x0051A7E0`; `AddVoyeur @ 0x0051A830`; `RemoveVoyeur @ 0x0051AD90` | | AD-57 | **Re-argued from TS-24 at Campaign P P7 (2026-07-30).** Outbound `RawMotionState.Actions` is always empty at runtime. The packer emits `num_actions` + per-action pairs (L.2b, `RawMotionState::Pack` 0x0051ed10) and the R3-W1 action FIFO capability exists (`AddAction`/`RemoveAction`/`ApplyMotion`/`RemoveMotion`); no production input path ENQUEUES autonomous actions yet because the emote/autonomous-motion feature surface is unimplemented. An empty list is byte-identical to retail's own no-pending-actions state, so this is a feature gap, not a divergence of existing behavior. | packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs`; FIFO `src/AcDream.Core/Physics/RawMotionState.cs` | Every currently-shipped movement packet matches retail byte-shape; the gap only manifests when emote-class autonomous actions are implemented. | When emotes land, forgetting to route them through the FIFO would silently drop them from the wire. | `RawMotionState::Pack` 0x0051ed10 | | AD-58 | **Re-argued from TS-40 at Campaign P P7 (2026-07-30).** Retail's `physics_obj->cell` null test ("placed in the world") is proxied by the explicit `PhysicsBody.InWorld` flag — set by `SnapToCell` and `RemoteMotion` construction, consumed by `CMotionInterp`'s detached-object link-strip guards. Equivalence: every acdream body that would have a null retail cell pointer has `InWorld == false` (bodies exist only for world entities; the flag flips exactly at placement/withdrawal), so the guards fire on the same population. A structural adaptation of retail's pointer-as-state idiom to acdream's explicit-flag idiom, not scheduled debt. | `src/AcDream.Core/Physics/PhysicsBody.cs` (`InWorld`); `src/AcDream.Core/Physics/MotionInterpreter.cs` (3 guard sites) | If a future path creates a body before world placement without clearing `InWorld`, the link-strip guards misfire where retail's null-cell test would not. | `CMotionInterp` link-strip guards raw @305xxx | @@ -189,11 +189,18 @@ readiness/requeue adaptation. See | AD-92 | **Filed 2026-08-13 at the #376/#388 review fix round (blast M6 / mechanism M4).** Two switcher adaptations with no retail counterpart: (1) the fullscreen refresh rate is the monitor's HIGHEST for the picked WxH — retail passed the device mode's own refresh as-is (`Device::ForceDisplayResolution`); (2) an invalid/unsupported fullscreen request is a logged refusal that leaves the window unchanged — retail attempted the switch and surfaced the device error. The persisted-flag divergence a refusal leaves behind is ISSUES #392. | `src/AcDream.App/Settings/DisplayModeSwitching.cs` (`TryFindRefreshRate`, the refusal paths); `src/AcDream.App/Settings/RuntimeSettingsTargets.cs` (`Apply`'s refused-mode logging) | Highest-refresh is strictly better on modern variable-refresh panels (retail predates them); refuse-and-log is #388's own no-crash requirement. | A capture comparing retail's exact chosen refresh for a mode will differ; a server/tooling flow expecting an error dialog on an invalid mode sees a console line instead. | `Device::ForceDisplayResolution @gmClient::Init 0x004047af`; docs/research/2026-08-13-376-388-{mechanism,blast}-review.md | | AD-94 | **Filed 2026-08-14 at the secure-trade feature.** Retail's `Event_AcceptTrade` payload (`Trade::Pack @0x005B9FF0`) appends two `PackableList` staged-item lists after the six fixed fields; acdream sends both as ZERO-COUNT lists. ACE parses and then discards the ENTIRE payload (`HandleActionAcceptTrade()` takes zero arguments — server trade state is fully self-derived; lane B §quirks), so the difference is unobservable against ACE; a byte-capture comparison against a real retail client would differ from offset 40. | `src/AcDream.Core.Net/Messages/TradeRequests.cs` (`BuildAcceptTrade`) | The `ContentProfile` pack layout was not byte-verified (ACE never reads it — no reader to check against), and guessing a wire struct violates the workflow; zero-count lists are well-formed `PackableList`s. | A future server that actually validates the accept echo would see empty item lists and could refuse or desync the accept. | `Trade::Pack @0x005B9FF0`; `GameActionAcceptTrade.cs:11-16`; `docs/research/2026-08-14-trade-laneB-wire.md` Table 1 | | AD-96 | **Filed 2026-08-14 at the OP8 re-gate fix round (key-name display).** Retail's `GetNameFromKey_Internal @0x00687800` falls back from the DAT string tables (key enum 4 → `0x2300000A`, meta enum 5 → `0x2300000B`) to the OS keyboard layout's own key name via DirectInput `IDirectInputDevice8::GetObjectInfo` (`tszName` — "SKIFT" on a Swedish layout). acdream reads the SAME layout-resident name data through Win32 `GetKeyNameTextW` instead (no DirectInput device exists in-process); on non-Windows hosts there is no OS lookup at all and the DIK-suffix spelling shows (un-localized English, e.g. "LSHIFT"). Mouse chords keep the pre-existing enum spelling — retail names them through the DirectInput mouse device. | `src/AcDream.App/Platform/PlatformKeyNameProvider.cs`; `src/AcDream.App/UI/Layout/RetailKeyNames.cs` (`Describe`, the mouse-device early-out) | GetKeyNameText and DirectInput's key names both come from the active keyboard-layout tables; adding a DirectInput device solely for name strings would be a heavyweight, dead-end dependency. Linux graphical work is parked at Slice L1. | A key whose GetKeyNameTextW name differs from DirectInput's `tszName` on some layout shows a slightly different caption than retail did; Linux graphical shows English DIK-suffix names where retail-on-Wine would localize; a mouse-chord caption reads as the Silk enum, not retail's device string. | `CInputManager_WIN32::GetNameFromKey_Internal @0x00687800`; `GetNameFromKey @0x00687F40`; `ControlSpecification::GetDIKName @0x0068ACB0`; `DBCache::GetDIDFromEnumStatic` category-4 probe 2026-08-14 (`KeyboardConfigLiveMountProbeTests.ProbeKeyboardFontsAndKeyNameStrings`) | +| AD-98 | **Filed 2026-08-15 at Campaign LA gate round 2 (character-select background tiling).** The LA8 root (0x1000039A) authors LeftEdge=TopEdge=RightEdge=BottomEdge=0 ("no anchor") in the installed DAT, so retail's own `UIElement::UpdateForParentSizeChange` (0x00462640) never resizes this element — it stays a fixed 800x600 rect in retail's own widget tree. Retail's generic sprite blit, `Graphic::Draw` (0x00693b20) dispatching to `Graphic::PutImage` (0x00693a30) for an exact/undersized destination or a modulo-wrapped tile loop otherwise, has no third "stretch" mode (confirmed against `BlitMode`, acclient.h ~line 3135, and `MD_Data_Image::m_drawMode`/`DrawModeType` — both are COLOR-blend selectors, not tile-vs-stretch geometry modes). The only way retail's whole pre-world scene (background AND buttons AND listbox together) can still fill an arbitrary window resolution with no element ever resizing and a blitter that can only copy-or-tile is that these "flow" screens render into a fixed 800x600 target and the WHOLE FRAME is stretched once at presentation, outside the UI element/sprite system. **COMPLETED 2026-08-15 (same gate round, misalignment follow-up):** the first substitution (resize the mounted root + stretch only its own background) stretched the ART but left the authored child widgets at 800x600 pixel positions — misaligned against a background whose painting CARRIES visual anchors (the World/Characters captions are art). The substitution now reproduces retail's whole-frame behavior: the root KEEPS its authored 800x600 extent, and while the screen is active `UiRoot.FixedCanvasSize` scales EVERY emitted quad (widgets, glyphs, art, dialogs) uniformly at `TextRenderer.AppendQuad`, with the exact inverse applied to mouse coordinates at the `UiRoot` entry points so hit-testing lives in canvas space. Non-uniform window/canvas stretch, retail-authentic (no letterbox). `UiDatElement` keeps retail's pure copy-or-tile blit; the interim `StretchOwnBackgroundToFill` flag is deleted. **Campaign CC CC4 review-fix round R1 (2026-08-15): `FixedCanvasSize` now has a single arbiter.** Character-creation can be simultaneously active on top of character-management (both author the same 800x600 canvas), so a raw property write from either controller was a last-writer-wins race with no owner — chargen's own Close() nulled the canvas out from under a still-active character-management screen underneath it. `UiRoot.DeclareFixedCanvas(object owner, Vector2 size)`/`RevokeFixedCanvas(object owner)` now own every production write: each screen declares on its activation edge and revokes on close/deactivate/dispose; the effective size is the current declaration set's value (asserted equal across every concurrent declarer — a future mismatched screen throws instead of silently winning), and it nulls only once EVERY declarer has revoked. The raw `FixedCanvasSize` setter stays public only for `UiRootFixedCanvasTests`' isolated scale-math coverage. | `src/AcDream.App/UI/UiRoot.cs` (`FixedCanvasSize`, `DeclareFixedCanvas`, `RevokeFixedCanvas`, `CanvasScale`, `MapWindowToCanvas`, `Draw`); `src/AcDream.App/Rendering/TextRenderer.cs` (`CanvasScale`, `AppendQuad`); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` and `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (both declare/revoke through the arbiter on activate/close/deactivate/dispose) | Reproducing retail's literal mechanism (an offscreen fixed-resolution UI render target scaled at presentation) would add RHI surface area for an identical pixel result; scaling at the one quad-emission chokepoint with an inverse input mapping is the same math applied one stage earlier, and the world-space HUD stays native because the scale is scoped to `UiRoot.Draw`. | Glyphs stretch with the frame (retail-authentic blur at large windows). **Gate round 2 filtering follow-up (2026-08-15):** the stretch now filters bilinearly — `TextureCache.GetOrCreateLinearUiTwin` gives every nearest-sampled UI texture (dat-font glyphs, composited icons) a linear-sampled twin that `TextRenderer.DrawSprite` swaps to while `CanvasScale != One` — matching retail's own bilinear-filtered presentation blit instead of aliasing the point-sampled art. Any future fixed-canvas screen (login/disconnected/datapatch) DECLARES via `UiRoot.DeclareFixedCanvas` while active and REVOKES on close — per-screen opt-in through the arbiter, not automatic and not a raw write. If a genuine present-time frame-stretch pass ever lands, this collapses into it. | `Graphic::Draw` 0x00693b20; `Graphic::PutImage` 0x00693a30; `UIElement::UpdateForParentSizeChange` 0x00462640; `BlitMode` acclient.h ~3135; `UIElementManager::CreateRootElement` 0x0045d020; `CharacterManagementLiveDatTests.RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf`; `UiRootFixedCanvasTests`; `CharacterScreensFixedCanvasArbiterTests` (the two-controller arbiter gate); `UiDatElementTests.CanvasScale_StretchesQuadGeometry_LeavesUvsAuthored`; the NON-UNIFORM (no-letterbox) aspect behaviour has no decomp citation of its own (batch review F7) — it is inferred from the mechanism chain and CONFIRMED by the user's live gate pass 2026-08-15 (stretched widescreen look accepted as matching retail memory) | +| AD-97 | **Filed 2026-08-14 at Campaign LA slice LA7a (character-restore request tail).** Retail's `CharacterRestore` request (`0xF7D9`) is ≥16 bytes: `CPlayerSystem::RestoreCharacter @0x0055d760` is, in the PDB-paired binary, `push 0x008173B4; push 0x008173B4; push guid; call Proto_UI::SendAdminRestoreCharacter @0x00546cf0`, and the callee packs BOTH constant `PStringBase*` arguments (`PStringBase::Pack @0x004fc6f0` emits ≥4 bytes even empty). Binary Ninja renders the two pushes as an uninitialized `edx` local plus `this` — a rendering artifact around constant `0x008173B4` (all 3 of its other pseudo-C appearances sit in provably-broken decompiles), but the arguments are real. acdream sends the 8-byte guid-only form. What the two constant strings contain is unresolved (a live cdb `db poi(0x008173b4)` would settle it). | `src/AcDream.Core.Net/Messages/CharacterRestore.cs` (`BuildRequestBody`) | ACE reads only `ReadUInt32()` and ignores any tail (`CharacterHandler.cs:331-385`), and holtburger ships guid-only from a real client command path against ACE successfully — the tail is unread by every server we can test against, and packing two strings whose CONTENT we cannot verify would be a guess. | A byte-capture comparison against a real retail client differs from offset 8; a future server that validates the full retail shape would reject our 8-byte request. | `CPlayerSystem::RestoreCharacter @0x0055d760` (binary bytes, not the BN rendering); `Proto_UI::SendAdminRestoreCharacter @0x00546cf0`; `PStringBase::Pack @0x004fc6f0`; ACE `CharacterHandler.cs:331-385`; holtburger `character_selection.rs:79-82`; LA7a Opus review F1 (2026-08-14) | | AD-93 | **Filed 2026-08-13 at social gate round 2, item 5 (the refused-drop notice port).** Two narrow gaps in the `ServerSaysAttemptFailed @0x0058EAE0` port: (1) **latched-guid preference** — retail's 0x00A0 dispatcher (`@0x0055B342`) PREFERS `prevRequestObjectID` over the wire guid when picking the item to name; acdream's `InventoryTransactionState.OnMoveFailed` instead REQUIRES the wire guid to match the latch (unobservable against ACE, which always sends the request's own guid on 0x00A0, and it protects a stale latch from mislabeling an unrelated failure — acdream has no retail-style latch timeout). (2) **unlatched request kinds** — retail latches `IR_MOVE`/`IR_WIELD` too; acdream's kind enum has no Move/Wield rows because wields ride `AutoWieldController` outside the single-request gate, so a refused wield/3D-move shows only the generic `HandleFailureEvent` leg, never "The X can't be wielded/moved". | `src/AcDream.Core/Items/InventoryTransactionState.cs` (`OnMoveFailed`); `src/AcDream.Core/Chat/InventoryFailureMessages.cs` (`Compose`'s absent Move/Wield rows); `src/AcDream.App/UI/ItemInteractionController.cs` (`OnInventoryRequestFailed`) | The match requirement is the compensating guard for the missing latch timeout; adding Wield/Move kinds means routing those sends through the single-request gate they deliberately bypass today — a behavior change beyond this gate item. | Only observable against a server that sends 0x00A0 with a guid that differs from the request's item (ACE never does), or on a refused wield/move, which shows no "can't be wielded/moved" verb line where retail would show one. | `ACCWeenieObject::ServerSaysAttemptFailed @0x0058EAE0`; the 0x00A0 dispatcher `@0x0055B342`; `ACCWeenieObject::RecordRequest @0x0058C220`; `docs/research/2026-08-13-confirm-and-weenie-error-display.md` §2 | +| AD-105 | **Filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 (skills info-box formula line clips at the frame's bottom edge).** `CharacterCreationSkillsPage`'s constructor clamps the description pane's (`0x100003fc`) live `Height` down to the bottom edge of the SIBLING gold decorative frame (`0x100003fa`, the SAME GF-12 corner/edge sprite family) whenever the frame's own authored bottom (Y=430 h=110 → 540, live-DAT-measured) sits ABOVE the pane's own raw bottom (Y=460 h=100 → 560) — a 20px overshoot that let a long skill's formula line draw into blank page space below the frame's visible border. Retail's own `ShowSkillsText @0x00481250` has NO code relationship between the two text panes and this frame (`UIElement_Text::SetText` only, no size/clip handoff) — the frame's authored geometry is used here as the only available ground truth for "the visible box," not a decomp-confirmed clip mechanism. | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (constructor, the `InfoBoxFrameElementId` clamp block) | No decomp evidence describes HOW retail reconciles a text pane authored taller than its own decorative frame — this is the most defensible non-arbitrary boundary (an AUTHORED sibling rect, not an invented pixel offset) but is still an INFERENCE, not a confirmed retail mechanism. If retail instead resizes/repositions the frame to the pane, or genuinely allows the same 20px overshoot, this clamp diverges from the real behavior. | A future decomp/cdb capture of `gmCGSkillsPage`'s real screen layout, or a user visual re-check specifically of a 4-5-line skill description (e.g. skill id 52, Deception), could reveal the clamp boundary is wrong (too tight/too loose) — worst case the formula line is STILL cut, one pixel short of what retail shows, or clipped MORE than retail does. | `gmCGSkillsPage::ShowSkillsText @0x00481250` (no frame/size relationship in the decompiled body); live-DAT geometry (`0x100003fa` Y=430 H=110, `0x100003fc` Y=460 H=100) | +| AD-104 | **Filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 (Skills info-box title/description overlap).** `CharacterCreationSkillsPage` force-sets `VerticalJustify = VJustify.Top` on the info-box title (`0x100003fb`) and description (`0x100003fc`) panes post-construction, compensating for a client-wide bug: neither element authors dat property `0x15`, and this port's shared unauthored-VJustify default (`ElementInfo.VJustify` field default `Center`, plus `ElementReader.cs`/`DatWidgetFactory.cs`'s import/build-time enum-mapping switches) resolves an absent `0x15` to Center — but retail's REAL ctor default (`UIElement_Text::UIElement_Text @0x004685ff`, `m_eVerticalJustification = 4`) resolves via `UIElement_Text::CalcJustification @0x00467260`'s actual enum table (`1=>Center, 3 or 5=>Bottom(far edge), else=>Top(near edge)`) to Top, not Center. The two panes' own AUTHORED boxes overlap by 75px (title Y=435 h=100, description Y=460 h=100, live-DAT-measured) — under the CORRECT Top default both render near their own box's top edge (25px apart) and no longer collide; under the port's current (wrong) Center default both cluster near the middle of their overlapping boxes and visually collide. | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (constructor, post-`_infoTitle`/`_infoText` resolution) | The shared mapping bug (`ElementReader.cs:507`'s switch, `DatWidgetFactory.cs:704`'s switch, and `ElementInfo.VJustify`'s field default) is CLIENT-WIDE and affects every DAT-imported `UiText` reaching the `Centered`/`RightAligned`/`OneLine` static paths or the multi-line honored-justification path — including already-shipped, visually-verified, FROZEN surfaces (vitals numbers, chat, main game UI, Options panel) that may rely on the CURRENT Center default for their existing correct-looking alignment. A page-scoped override for exactly the two elements proven broken avoids a client-wide regression sweep this session has no budget for; the shared fix is filed as ISSUES.md #410 for its own dedicated investigation. | If ISSUES #410's shared fix ever lands, this page's override becomes redundant (harmless but should be removed in the same commit, since the corrected shared default would already resolve to Top). Until then, any OTHER DAT-imported `UiText` with an unauthored `0x15` that happens to sit close to a sibling text element (the same "two 100px-tall overlapping boxes" shape) can exhibit the same visual-collision symptom, undiscovered until its own gate round. | `UIElement_Text::UIElement_Text @0x004685ff` (ctor default = 4); `UIElement_Text::CalcJustification @0x00467260` (real enum semantics); ISSUES.md #410 | +| AD-100 | **Filed 2026-08-15 at the Campaign CC CC2 review, finding F2 (unrequested `0xF643` handling).** When a `0xF643` (`CharGenVerificationResponse`) arrives with NO outstanding create/restore request, acdream DROPS the message with a once-per-session stderr log. Retail has no such gate: `Handle_CharGenVerificationResponse @0x0055E8B0` processes whatever arrives, discriminating create-vs-restore by its OWN persistent verification state (case 1 branches on `GetVerificationState() == PENDING` → new `CharacterIdentity` + `AddIdentity`, else unpacks into the existing identity at `slot`) — an unsolicited reply would be applied against whatever that state happens to be. acdream's transport-level latch (`PendingCharGenVerificationRequest`) is the equivalent discriminator, but when it is `None` there is no state to apply the reply against, so the honest move is drop-and-log rather than guessing a family. | `src/AcDream.Core.Net/WorldSession.cs` (the `CharGenVerificationResponse.ResponseOpcode` arm in `ProcessDatagram`; `_loggedUnexpectedCharGenVerificationResponse`) | Processing an unsolicited reply requires retail's persistent chargen verification state, which lives in CC3's Runtime owner, not the transport. Until then a reply with no outstanding request is either a server bug or a latch-lifecycle bug on our side — surfacing it in the log beats silently misrouting it to an arbitrary event. Pinned by `WorldSessionCharacterCreationTests.ResponseWithNoOutstandingRequest_IsDroppedAndNeverMisattributed`. | A server that sends a spontaneous/duplicate `0xF643` (ACE can double-send NameInUse — see the CC2 review's F3 note) has its second copy dropped here, where retail would re-process it. If CC3's verification gate ever needs retail's re-process semantics, this drop must move behind that owner's state. | `Handle_CharGenVerificationResponse @0x0055E8B0`; `CharGenState::GetVerificationState`; CC2 review F2 (2026-08-15) | +| AD-102 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Heritage page's Viamontian button and the Town page's Sanamar button).** Retail gates BOTH controls behind `CPlayerSystem::AccountHasThroneOfDestiny`: `gmCGHeritagePage::ListenToElementMessage @ 0x00483860` shows `MakeToDWarningDialog` instead of selecting Viamontian (element `0x100003c3`) for a non-ToD account, and `gmCGTownPage::ListenToElementMessage @ 0x0047c480` does the same for Sanamar (element `0x1000040b`, `startArea` index 3 — also the reason `CharGenState::RandomizeStartArea`'s ToD-aware `RandInt(3 or 4)` bound exists). acdream's `ChargenOptions` (CC1) carries no account/DLC-ownership signal anywhere in the model, so both controls ship WITHOUT the gate — every installed heritage/town in `Options.HeritagesById`/`Options.StarterAreas` is always selectable, matching what a ToD-owning account would see. | `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`HeritageByButtonId[0x100003C3u]`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`StartAreaByButtonId[0x1000040Bu]`, `Randomize`) | ACE's server-side `CharacterCreate` handler never checks ToD ownership either (the field is purely a retail-client UI gate), so accepting the selection unconditionally never produces a request the emulator would reject; adding an account-ownership model to CC1's DAT-only `ChargenOptions` is out of this slice's scope and would need its own design (where does the "ToD owned" bit come from — account service, launcher config, a new env flag?). | None observable against ACE. A future retail-parity gate that specifically checks "does a non-ToD account get warned off Viamontian/Sanamar" will fail until an account-ownership signal exists to gate on. | `gmCGHeritagePage::ListenToElementMessage @ 0x00483860`; `gmCGTownPage::ListenToElementMessage @ 0x0047c480`; `gmCGTownPage::SetTown @ 0x0047c360`; `CharGenState::RandomizeStartArea` (DoRandom case 4, `RandInt(hasToD ? 4 : 3)`) | +| AD-99 | **Filed 2026-08-15 at Campaign LA gate round 2 finding 1 (character-select Exit button).** On a confirmed Exit, acdream closes the client through the existing graceful window-close path (`d.Window.Close`, the same seam `GameplayInputCommandController`'s in-world Escape fallback already uses) instead of retail's real post-confirm behavior: `RecvNotice_CloseDialog`'s case-1 arm queues UI mode `0x10000009`, which `gmEpilogueUI::Register` claims — a brief epilogue/farewell screen — before the process actually terminates. The confirmation dialog itself (`MakeConfirmExitDialog`, its exact `ID_CharacterManagement_ConfirmExit` text, and the `m_confirmExitDialogContext != 0` re-entry guard) IS ported faithfully; only the post-confirm destination differs, the same shape as AD-74's Options-panel exit. | `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (`RequestExit`); `src/AcDream.App/UI/RetailUiRuntime.cs` (`CharacterSelectionRuntimeBindings.RequestExit`); `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs` (`d.Window.Close` binding) | acdream has no `gmEpilogueUI` port (out of scope this round); reusing the ONE existing graceful-shutdown seam keeps `disconnected`/`exited` status events firing through `GameWindow.OnClosing` → `CompleteShutdown` rather than inventing a second shutdown path, per explicit direction for this finding. | A user confirming Exit sees the window close immediately instead of retail's brief epilogue screen; a future feature wanting to reproduce that screen (or an intermediate "logged off, returned to character select" state) has no seam yet — same gap class as AD-44. | `gmCharacterManagementUI::MakeConfirmExitDialog @0x004ed250`; `RecvNotice_CloseDialog @0x004ed760` case 1; `gmEpilogueUI::Register(0x10000009)` @0x0047a680; `gmCharacterManagementUI::OnAction @0x004ed410` (Escape key, unported — button-only this round) | --- -## 3. Documented approximation (AP) — 142 active rows (AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 161 active rows (AP-231 filed 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the Skills page formula-connector-text approximation in `ComposeFormula`, see the row's own text for the full disclosure of what is byte-verified versus best-derived; AP-213 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the remaining flat-list-vs-four-bucket-sorted-model half is now ported: `ChargenSkillDetail`/`ChargenSkillFormula` (Core) thread `SkillBase.MinLevel`/`Description`/`Formula` from the global SkillTable through `ChargenOptions.TryGetSkillDetail` (`ChargenTableReader.Project` populates it, live-DAT-pinned at 38 entries — 23 MinLevel<=1/15 MinLevel==2, matching the Batch F investigation's own recorded finding exactly), and `CharacterCreationSkillsPage` now groups every costable skill into `SkillBucket` (Specialized/Trained/UseableUntrained/UnuseableUntrained, `UpdateSkillEntry`'s own `iMinlevel <= 1` test), sorts each bucket alphabetically (`InsertEntrySorted`'s `wcscmp`, ported as `string.CompareOrdinal`), and builds one `Templates[0]` header row per bucket ahead of that bucket's `Templates[1]` skill rows — `DoSkillRecords`'s own unconditional 4-header-then-populate build order. A level change re-buckets the row (detected per-refresh against each row's own cached bucket, then a full rebuild — the observable placement matches retail's incremental single-row `InsertEntrySorted` move without reproducing its internal mechanism, a documented and harmless substitution). 3 new fixture tests (`SkillsPage_BucketHeaders_AlwaysBuildAllFour_InRetailOrder`, `SkillsPage_UntrainedSkill_BucketsByMinLevel`, `SkillsPage_AdvancingASkill_MovesItsRowIntoTheNewBucket`) plus 1 new live-DAT test (`InstalledSkillTable_GlobalSkillDetails_MinLevelDistributionMatchesCostCoverage`); AP-216/AP-217 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 1 — both rows' STOPPED items are now landed: `CharacterCreationUiController.AppearancePalSetSource`/`AppearanceClothingTableSource`/`AppearancePaletteColorSource` wire a DAT-backed `ChargenAppearanceCatalog` into the Appearance page from `LivePresentationComposition` (mirroring the existing `AppearancePreviewControl` seam), and `UiButton`/`UiDatElement` both gained a per-instance `Tint` property threaded into every existing `DrawSprite` call they make; `CharacterCreationAppearancePage` now sets `Tint` directly on each swatch button and the GradCircle element instead of layering a flat-fill `ChargenSwatchColorTile` overlay on top (that class is deleted) — a genuine multiplicative sprite tint on the widget's OWN authored art, matching retail's `SurfaceWindow::BlitAndColor(..., Blit_Multiply, color)` exactly rather than approximating it with an opaque rectangle. Both fixture test suites (`CharacterCreationAppearancePageSwatchColorTests`, 8 tests) and the live-DAT color pins (`ChargenAppearanceCatalogColorTests`) pass unchanged against the new mechanism; AP-218 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-6) — `gmCGAppearancePage::Update`'s heritage-flavored static Hair/Eyes/Skin spin caption (`ID_CharGen_HairStyle`/`_Eyes`/`_Skin`, Gearknight `GearText_*`, Olthoi/OlthoiAcid `OlthoiText_*`) is now ported verbatim by `RefreshSpinCaptions`, replacing the prior ordinal substitution outright — see AP-215's own rewritten row for what remains open (the icon-thumbnail gap, restated); recount at this same edit: the row count this header carried before Batch B was already one LOW relative to the physical table (Batch A's own ending state: header said 164, the physical table already held 165 rows — verified by direct count against that commit) — a pre-existing drift this edit corrects to the counted total, not an artifact of Batch B's own net change (F12 correction, gate round 1 closeout, 2026-08-16: this note originally said "one high", the inverted direction — the header was UNDER-counting, not over-counting); AP-222 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-11b) — the Appearance spins' current-part highlight and the Town buttons' Normal-to-white caption swap both port retail's actual mechanism (per-state label color/outline commit off the REQUESTED retail state id, independent of art-media availability — `UiButton.SetPerStateLabelStyle`/`ComputeRequestedStateId`), closing the row's own "not yet resolved which side is wrong" question: NEITHER client's spin ART changes (no Highlight media exists on either), but BOTH clients' spin TEXT does, matching retail's `SetState(1)`/`SetState(6)` property commit exactly (live-DAT-measured 218,167,85 -> 255,221,131, outline off -> on); AP-215 NARROWED the same batch (GF-9) — item 1 (the swatch-selection substitution) is RETIRED now that the real companion-overlay mechanism (`SetColor`'s `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) is ported (`CharacterCreationAppearancePage`'s nine `SwatchOverlayIds`), leaving only item 2 (the icon-less style-spin ordinal label) open; AP-230 filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13) — the chargen-scoped-vs-general-importer-wide honor split for dat property 0x3B (Invisible: `UIElement::OnSetAttribute` case 8 hides an element), with the general client-wide honor deferred as its own visual gate (docs/ISSUES.md #408, 1,083 elements affected); AP-213 NARROWED the same gate round (GF-5) — the Skills page's click-to-advance/double-click-retreat single-button substitution is RETIRED now that the real per-row `pSkillUpButton`/`pSkillDownButton` arrows are wired to retail's own plain-click dispatch, leaving open only the flat-list-vs-four-bucket-sorted-model half; AP-229 filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1 — the screen-layering divergence: retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch where acdream's CC7 keeps both `CharacterManagementUiController` and `CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes them; AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -201,6 +208,14 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| AP-231 | **Filed 2026-08-16 at the Campaign CC gate round 1 closeout, Group 2 (Skills page info-box completion).** `CharacterCreationSkillsPage.ComposeFormula` ports `gmCGSkillsPage::MakeSkillFormula @0x00480e10` with HIGH CONFIDENCE for the `"Formula : "` prefix, the per-attribute `"(%u x %s)"`-vs-bare-name choice (a term's own multiplier > 1 gets the parenthesized form, else just the attribute name), the `" / %u"` divisor suffix (gated on `Divisor != 1`), and the `" +%u"` additive-bonus suffix (gated on `AdditiveBonus != 0`) — every one of those is a directly-read compiled string literal or a field the DatReaderWriter binding already exposes by name (`SkillFormula`'s six fields map 1:1 onto the decompiled struct's own `_w/_x/_y/_z/_attr1/_attr2` offsets, confirmed by their exact 0x28/0x2c/0x30/0x34/0x38/0x3c stride). LOWER CONFIDENCE: the CONNECTOR text between a two-attribute formula's two terms. This port renders `" + "` — the well-known "(Attr1 + Attr2) / N" shape most published AC skill formulas use — but the decompiled function's own two candidate connector literals (`data_7a01a4`, appended between the terms; `data_797584`, appended again immediately after BOTH terms are present, an apparently redundant second literal whose exact role this session could not resolve) sit behind reference-counted `PStringBase` appends whose actual wide-character content Binary Ninja's HLIL does not surface as a literal — this session had no live cdb attach and no running Ghidra MCP instance to recover the raw bytes. A two-attribute skill's formula therefore renders as `"Formula : (2 x Strength) + Endurance / 4 +2"`-shaped text that is very likely retail-correct in STRUCTURE but not byte-verified. | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (`ComposeFormula`, `AppendAttributeTerm`) | The single-attribute majority of skills render byte-correct today; only the minority of two-attribute formulas carry the unverified connector, and the gap is disclosed in the method's own doc rather than silently guessed. | A live retail capture of a two-attribute skill's formula text (e.g. via the cdb toolchain) could reveal `" + "` is wrong — the actual connector might be `" and "`, `" / "` (an OR-style formula, common for some AC skills that use whichever attribute is higher), or something else the two unresolved literals encode; `data_797584`'s role (appended after both terms) is also unexplained and could indicate a THIRD text segment this port omits entirely. | `gmCGSkillsPage::MakeSkillFormula @0x00480e10`; `SkillFormula` struct (`acclient.h`) | +| AP-230 | **Filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13).** Retail's `UIElement::OnSetAttribute @0x00462d80` case 8 (`GetPropertyName()-0x33==8`, property id `0x3B`, "Invisible") hides ANY element authoring that property `true` via `SetVisible(value==0)` — a general, importer-level mechanism. The blast-radius sweep this fix's investigation ran found **1,083 elements client-wide** author `P0x3B=true` (the Summary page's GM-only `0x10000403`/`0x10000494` labels among them — the user-reported "-Non-admin or Non-envoy" leak). Honoring the flag client-wide in `LayoutImporter`/`DatWidgetFactory` is its own separately-gated visual sweep (docs/ISSUES.md #408, since a mis-hidden element among 1,083 untested ones would silently vanish a control nobody asked to disappear); this fix instead reads the flag as a PURE DATA ADDITION (`ElementInfo.Invisible`, `UiElement.AuthoredInvisible` — populated everywhere, acted on nowhere by the shared path) and only the chargen screen's own mount (`CharacterCreationUiController.HideAuthoredInvisibleElements`, called once at construction) walks its own subtree and hides whatever the dat itself marked hidden. **Second narrow honor added (F5/F6, gate round 1 closeout, 2026-08-16):** `LayoutImporter.BuildWidget`'s Batch C `UiText or UiField` un-consumed-children carve-out now ALSO honors `AuthoredInvisible`, scoped to exactly the children it builds through that one loop — a live-DAT sweep found the chat transcript's new-text indicator (`0x1000048C`) is one of the 37 carve-out (layout, element) pairs' children and authors `Invisible=true` itself, so the carve-out was building it as a visible phantom element retail never shows. Verified in both directions (`MediaBearingChildSweep_EnumeratesWhichAffectedChildrenAuthorInvisible` + `MainGameUiAndChatInput_MediaBearingChildrenNowBuildAsRealWidgets`): the chat indicator now builds hidden, and the eight gold-frame pieces this carve-out ALSO covers do not author Invisible and stay visible. Still narrower than #408: only these two honor sites exist (chargen's own screen walk; this one carve-out loop) — every OTHER AuthoredInvisible-bearing element client-wide, reached through the ordinary generic-container recursion, remains data-only. | `src/AcDream.App/UI/Layout/ElementReader.cs` (`ElementInfo.Invisible`, `ApplyCanonicalLegacyProjection`'s `0x3Bu` read); `src/AcDream.App/UI/UiElement.cs` (`AuthoredInvisible`); `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget`'s passthrough assignment AND the `UiText or UiField` carve-out's own honor); `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`HideAuthoredInvisibleElements`) | The scoped fix closes the ONE reported, live-DAT-confirmed symptom (chargen's two GM labels) without touching any of the other 1,083 elements' visibility, each of which needs its OWN visual gate before the general importer-wide honor can ship safely — narrowing blast radius to a screen this same gate round is already re-testing end-to-end. | Every OTHER screen with an authored-invisible element still renders it (the general honor is #408, not yet shipped) — this row and #408 both retire together once the general sweep lands and passes its own visual gate. | `UIElement::OnSetAttribute @0x00462d80` (case 8, `SetVisible(value==0)`) | +| AP-229 | **Filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1.** Retail does NOT stack screens: `UIFlow::QueueUIMode @0x004793c0` sets `_nextMode`, then `UIFlow::UseNewMode @0x004796a0` calls `_curUI->vtable->Show(0)` on the current framework, immediately DESTROYS it (`_curUI->vtable->__vecDelDtor(1)`), constructs the new framework, and calls `Show(1)` on it — so retail TEARS DOWN `gmCharacterManagementUI` the instant Create fires and RE-CONSTRUCTS it when Exit confirms (Exit-confirm's `RecvNotice_CloseDialog @0x004e9883-0x004e989c` issues `QueueUIMode(0x1000000a)`, the reverse transition). acdream's CC7 instead keeps BOTH `CharacterManagementUiController` and `CharacterCreationUiController` mounted as permanent siblings under the shared `Host.Root` and only reveals/occludes them (`Root.Visible` + `_host.BringToFront(Root)`) — this was already true since the CC4 FixedCanvas-arbiter work, but CC7 made it the production Create/Exit path rather than a dev-only shortcut. **Confirmed working within this narrower surface:** selection/world-name persistence across the round trip is retail-faithful (retail's own `UIPersistantData::m_iidSelectedAvatar`, `UIPersistantData::UIPersistantData @0x00479a00`, persists exactly this data across the destroy/reconstruct — acdream gets the same outcome for free by never tearing the screen down at all); input cannot bleed from the visible chargen screen through to the occluded management screen underneath (chargen's `Root.ClickThrough = false` over the full authored canvas, plus a `_host.BringToFront(Root)` call every tick chargen is open, keeps it strictly on top and input-opaque); and the two controllers share ONE `RetailDialogFactory` instance (`RetailUiRuntime.EnsureDialogFactory`), so `UiRoot.Modal` stays a single coherent stack instead of two independent ones. **Residual risk the reviewer named:** because character-management is never deactivated while chargen sits on top of it, its own `ReconcileDialogs` keeps running every tick (`CharacterManagementUiController.cs:663-667`'s `if (snapshot.Error is { } error)` arm) and can call `EnsureError` → `_dialogs.MakeMessage(...)` on the SAME shared factory chargen uses. `RetailDialogFactory.RefreshModal` (`RetailDialogFactory.cs:587`, `_host.Modal = _openOrder[^1].View?.Root`) always promotes the most-recently-opened dialog to `Modal` — an inbound `CharacterError` reaching the occluded management screen while chargen is the visible, active screen could take `UiRoot.Modal` away from chargen and hand it to a dialog owned by the screen underneath. Retail cannot have this race by construction: character-management's C++ object no longer exists once Create fires, so there is nothing left to receive a stray inbound event. **Dialog-as-sibling addendum (F3, gate round 1 closeout, 2026-08-16):** the same flat-sibling-list mechanism that motivates this row ALSO covers `RetailDialogFactory`'s own open dialogs — a dialog's root is a direct sibling of the chargen/character-management screen roots under the SAME `Host.Root`, and `RetailWindowManager.BringToFront` is a simple "highest ZOrder among siblings + 1", so whichever sibling's own `BringToFront` call runs LAST in a frame wins z-order. This was GF-15's actual root cause (a dialog opened while chargen is active got buried the very next frame because the screen's own per-tick `BringToFront` ran after the dialog's one-time open-time raise) and is now closed by `RetailDialogFactory.Tick()` re-raising every open dialog, in `_openOrder`, every tick — but the underlying divergence (dialogs and screens sharing one z-order list at all, where retail's dialog layer is architecturally separate from `UIFlow`'s single current framework) remains; any FUTURE sibling that calls its own unconditional per-tick `BringToFront` could reintroduce the same failure class against a dialog OR against chargen itself. | `src/AcDream.App/UI/RetailUiRuntime.cs:3845-3847` (`ConfigureCharacterManagement`'s cross-screen `RequestCreate` seam, both controllers mounted as permanent siblings); `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs:465-473` (`Tick`'s reveal/occlude, not destroy/reconstruct); `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs:265` (`Root.ClickThrough = false`); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs:663-672` (`ReconcileDialogs`' `snapshot.Error` arm, still ticking underneath); `src/AcDream.App/UI/Layout/RetailDialogFactory.cs:587` (`RefreshModal`, the shared `Modal` stack) | Both screens existing as permanent siblings is deliberately simpler than a byte-port of retail's destroy/reconstruct lifecycle (no framework-factory table, no `Show`/`__vecDelDtor` lifecycle to replicate), and every observable behavior a user can drive through the ordinary UI today matches retail (selection persists, input doesn't bleed, dialogs stay single-stacked) — the residual is a narrow, not-yet-observed race on a specific inbound-error timing, not a general design flaw. | If an inbound `CharacterError` lands on the character-management channel while chargen is the visible, focused screen, `UiRoot.Modal` could flip to a dialog owned by the occluded screen underneath, stealing input from the still-visible chargen screen — a state retail cannot reach because the occluded screen simply does not exist there. | `UIFlow::QueueUIMode @0x004793c0`; `UIFlow::UseNewMode @0x004796a0` (`Show(0)` → `__vecDelDtor(1)` → construct → `Show(1)`); `RecvNotice_CloseDialog @0x004e9883-0x004e989c` (Exit-confirm's `QueueUIMode(0x1000000a)`); `UIPersistantData::UIPersistantData @0x00479a00` (`m_iidSelectedAvatar`) | +| AP-228 | **Filed 2026-08-16 at the CC5 re-review residual round (R4).** The Summary listbox's skill-row KEY (the skill's display name) sources from `ItemAppraisalTextFormatter.SkillName(int)` — a hardcoded English `switch` over the 54 skill ids — where retail's own `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0` builds that same key from the DAT-sourced `SkillBase->_name` field via a `%hs` format substitution (`data_79f3f0`, `0x0047b90f`-`0x0047b915`). Same divergence CLASS as AP-226 (a hardcoded acdream string standing in for a DAT-sourced retail field) but the polarity is REVERSED: AP-226 is retail-static-vs-acdream-DAT-sourced, while here retail is the DAT-sourced side and acdream is the hardcoded side. The identical pattern is ALSO present at a second call site, CC4's Skills page (`CharacterCreationSkillsPage`), which builds its own row labels through the SAME `ItemAppraisalTextFormatter.SkillName` call — not a second, independent divergence, the same one surfacing twice. | `src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs` (`SkillName`), consumed by `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`AddSkillBucket`) and `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` | `SkillName` already backs every OTHER retail skill-name surface acdream has shipped (item-appraisal skill lines, wield-requirement text, usage-limit text — `ItemAppraisalTextFormatter`'s whole existing surface) — the Summary/Skills chargen pages reusing it keeps one skill-name source across the client instead of introducing a second, DAT-reading one for chargen alone. English-only is consistent with the rest of the client's current localization posture (no other surface reads a localized skill name from the DAT either). | A non-English or modded DAT install would show its real, localized skill names on retail's character sheet and item-examine windows but acdream's chargen Summary/Skills pages would keep showing the hardcoded English name regardless — a localization-only divergence, never a wire or gameplay difference (the skill id sent over the wire is unaffected). | `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0` (`data_79f3f0`, `%hs` substitution `0x0047b90f`-`0x0047b915`) | +| AP-227 | **Filed 2026-08-16 at the Campaign CC CC5 review-fix round, F9 (the Summary name field's empty-commit behavior).** Byte-decoded `gmCGSummaryPage::ListenToElementMessage @0x0047bf40` (`~0x0047bf93`): the length field it reads is NUL-inclusive (an empty field's length is 1 — the SAME finding AP-225's retirement/AP-226 both cite), and the WHOLE commit block — the `>32` check, `CharGenState::SetName`, AND `DoNameLimitDialog` — sits behind `if (length != 1)`. Blurring an EMPTIED field in retail is therefore a complete no-op: `CharGenState.name` stays whatever it held before, and the field visually shows empty while the internal name (what `DoFinish` actually sends) does not change. `CharacterCreationSummaryPage.CommitNameFromField` instead calls `SetName` unconditionally, including for an empty commit — the state always matches what the field just showed. | `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`CommitNameFromField`) | Porting the exact skip was evaluated and rejected: it would fight `Refresh`'s own field-sync block (the F1 fix) — the NEXT unrelated Runtime revision bump (e.g. changing an attribute on another page, then returning to Summary) would see `field.Text ("") != snapshot.Name (the stale unchanged name)` and forcibly restore the OLD name into the emptied field, a spontaneous repopulation retail's own non-continuously-refreshed UI never produces. Always-clearing avoids that new failure mode at the cost of retail's exact one-frame field/state divergence. | A pixel-level side-by-side against retail would show: blur an emptied field, don't retype, click Finish — retail creates the character under the OLD (uncleared) name; acdream shows the `NoNameWarning` dialog instead (state genuinely empty). A narrow, one-interaction-wide behavioral difference, never silent (both paths produce a visible outcome, just a different one). | `gmCGSummaryPage::ListenToElementMessage @0x0047bf40` (`~0x0047bf93` length gate, `~0x0047bfb1` the gated block); `CharGenState::SetName` | +| AP-226 | **Filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 (the Summary listbox's Profession/Gender/Heritage/Starting Town label sources).** Retail's `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0` sources these four labels from four STATIC wide-string tables baked into the binary's data section: `pcProfessions[0x7] @ 0x008191a8` ("Custom", "Bow Hunter", "Swashbuckler", "Life Caster", "War Mage", "Wayfarer", "Soldier"), `pcGender[0x3] @ 0x008191c4` ("?", "Male", "Female"), `pcHeritage[0x5] @ 0x008191d0` ("?", "Aluvian", "Gharu'ndim", "Sho", "Viamontian"), `pcTown[0x4] @ 0x008191e4` ("Holtburg", "Shoushi", "Yaraq", "Sanamar") — each indexed directly by the character's `template_`/`mGender`/`mHeritageGroup`/`startArea` field, each guarded by an upper-bound-only range check (`template_ <= 6`, `mGender <= 2`, `mHeritageGroup <= 4`, `startArea <= 3`) with NO append at all when the index is out of range. Concretely: **`pcHeritage`'s guard is `mHeritageGroup <= 4` — heritage ids 5 and above (every NON-HUMAN heritage: Tumerok, Gearknight, Lugian, Empyrean, Penumbraen, Shadowbound, Undead, Olthoi, OlthoiAcid) are never appended, so retail's own Summary page renders a BARE `"Heritage: "` with no name at all for a non-human character** — a genuine retail quirk, not a decompiler artifact (confirmed by the same guard shape on all four tables). `CharacterCreationSummaryPage`'s port instead sources every label from the already-loaded `ChargenOptions` DAT model (`heritage.Templates[i].Name`, `gender.Name`, `heritage.Name`, `options.StarterAreas[i].Name`) and prints the literal `"None"` when the index is unresolved, for EVERY heritage including non-human ones — never a bare label. | `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`ProfessionName`, `GenderName`, `RebuildListbox`'s `"Heritage: " + heritage.Name`, `StarterAreaName`) | The DAT-sourced names are the SAME strings a player already sees on every earlier chargen page (Heritage/Profession/Town pages all source from the identical `ChargenOptions` model) — reusing them keeps the Summary page internally consistent with the rest of the screen rather than introducing a second, static, English-only label source that could drift from the DAT (localization, a modded heritage table) or blank out for heritages retail's own hardcoded table never anticipated. | A pixel-level side-by-side against retail would show a non-human character's Summary "Heritage:" row completely empty of a name in retail (an accepted retail bug/limitation) versus acdream always showing the real heritage name — a cosmetic improvement, never a correctness or wire-format difference; a non-English/modded DAT install could theoretically show acdream a label retail's hardcoded English table never had, which is again strictly more informative, not less. | `pcProfessions[0x7] @0x008191a8`; `pcGender[0x3] @0x008191c4`; `pcHeritage[0x5] @0x008191d0`; `pcTown[0x4] @0x008191e4`; `gmCGSummaryPage::SetSummaryText @0x0047b1d0` (the four guard+append sites) | +| AP-224 | **Filed 2026-08-15 at Campaign CC slice CC5 (the Summary listbox content).** Retail's `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0` walks FOUR skill buckets (Specialized, Trained, UseableUntrained, UnuseableUntrained) and lists every skill name in each, via a nested loop over `skillRecordList`. `CharacterCreationSummaryPage.AddSkillBucket` lists Specialized and Trained only, skipping the two Untrained buckets — mirroring AP-213's own already-accepted Skills-page simplification precedent (same class of cut: presentation grouping, not correctness). Health/Stamina/Mana values reuse `CharacterCreationProfessionPage.Refresh`'s own already-cited `UpdateAttributeValues @ 0x00482450` formulas (Health=Endurance/2, Stamina=Endurance, Mana=Self) rather than this page's OWN `SetSummaryText` call site, whose two `GetAttribute` calls for Health/Stamina both show a literal attribute index of `2` in the decompiled pseudo-C — a decompiler-ambiguous pair the cleaner Profession-page citation sidesteps rather than reproduces uncritically. | `src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs` (`RebuildListbox`, `AddSkillBucket`) | The two Untrained buckets would list the ~40+ skills the player did NOT touch — volume without decision-relevant information for a pre-Finish review screen; every skill's actual cost/level data remains identical and inspectable on the Skills page itself. The Health/Stamina/Mana citation choice favors a decomp site with an unambiguous formula over one with a decompiler artifact. | A player scanning Summary for "what am I NOT trained in" has to go back to the Skills page instead of seeing it listed here — a discoverability gap, not a correctness gap; the row TEMPLATE mechanism itself (three retail row types: single-line, header, key/value pair) is ported exactly, live-DAT-probe-confirmed, not simplified. **Correction, CC5 review-fix round F3 (2026-08-16): this last claim was FALSE as originally shipped — the skill rows this row's own `AddSkillBucket` builds used template 0 (single line, name only) instead of template 2 (key/value pair, `CharGenState::GetSkillScore @ 0x005C4B50` as the value) and its bucket headers were added lazily (only when the bucket had a match) instead of retail's own unconditional add. Both are fixed this round (`RetailSkillFormula.CalculateChargenScore`, wired via the new `CharacterCreationRuntimeBindings.GetSkillScore` binding) — the "ported exactly, not simplified" claim is true again, but it was not verified against the ACTUAL row template/value at CC5 ship time, only against the listbox's INDEX/TYPE shape — and even now that claim covers the row's VALUE and TEMPLATE shape only. **Further correction, CC5 re-review residual round R4 (2026-08-16): the row's KEY (the skill name) was never covered by the "ported exactly" claim at all — it is a separate, pre-existing divergence (AP-228) this fix neither introduced nor closed.** A SEPARATE, more severe bug surfaced writing this round's own regression test (F12(d)): `CharacterCreationSummaryPage`'s constructor never assigned `_list.TemplateResolver` at all (every sibling `UiTemplateListBox` owner — `CharacterCreationSkillsPage`, `CharacterManagementUiController`, every Options-panel controller — does this in its own constructor; this page never did), so `ResolveTemplateRow`'s own null-resolver guard made EVERY `RebuildListbox` call a silent no-op — the Summary listbox rendered NO rows at all (not just wrong-template skill rows) from CC5's ship date until this fix. Also fixed this round (`CharacterCreationSummaryPage`'s new `templateResolver` constructor parameter).** | `gmCGSummaryPage::SetSummaryText @ 0x0047b1d0`; `CharacterCreationProfessionPage.Refresh`'s own `UpdateAttributeValues @ 0x00482450` citation; `CharGenState::GetSkillScore @ 0x005C4B50`; `SkillFormula::Calculate @ 0x00591960` | +| AP-223 | **Filed 2026-08-15 at Campaign CC slice CC5 (the F12 amendment's own explicit ask — see AP-214's now-retired "Latent Finish-path interaction" note).** `RuntimeCharacterCreationState.TryBeginFinish` gains a NEW local refusal, `HeritageOrGenderUnset`, checked right after the empty-name check. Retail's own `gmCharGenMainUI::DoFinish @ 0x004E9170` has NO such check in the decompiled code — but it doesn't need one: `RandomizeCharacter` at ctor time (now ported, see AD-101/AP-212/AP-214's history) guarantees heritage+gender are ALWAYS real by the time any page — including Summary/Finish — exists. This refusal is acdream's OWN defensive backstop for a caller that reaches `Finish` without that screen-open roll ever having run (a headless bot driving `RuntimeCharacterCreationState` directly, or a future caller that bypasses `CharacterCreationUiController.Open`). Under the ordinary UI it is normally unreachable (the roll always fires first). | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`RuntimeCharacterCreationLocalRefusal.HeritageOrGenderUnset`, `TryBeginFinish`) | Retail's own guarantee is architectural (a roll that always runs before any page exists), not a runtime check — acdream's UI reproduces the roll (`CharacterCreationUiController.Open` → `RollOpeningCharacter`) but a direct Runtime caller could still skip it, so a local refusal is the honest choice over silently sending a heritage-0/gender-0 wire request ACE would likely reject anyway for unrelated reasons. | A caller that bypasses the normal screen-open path and calls `Finish` before ever selecting heritage/gender gets a local refusal instead of a wire round-trip to discover the same failure — no server-visible consequence either way. | `gmCharGenMainUI::gmCharGenMainUI @0x004e7eb0` (`~0x004e81f5-0x004e8218`, the ctor-time roll); `CharGenState::RandomizeCharacter @0x005c6d80`; `gmCharGenMainUI::DoFinish @ 0x004E9170` (no heritage/gender check present) | | AP-206 | **Filed 2026-08-11 at Campaign OP gate 4 (#382).** `UiButton.TrySetRetailState`'s DirectStateId branch now requires REAL `""`-keyed media (`HasStateMedia("")`) before accepting a DirectState transition; a `_mediaInfo.States` entry that exists ONLY as a property bag (every button carries one, holding ToggleBehavior/RolloverEnabled/etc regardless of whether it authors blank media) no longer counts. A reference-identity-verified live-DAT probe found the chat window's four floating-window indicator buttons (`0x10000522`-`0x10000525`) resolve their own correct `ActiveState="Normal"` at construction, then get blanked to `""` moments later in the SAME `LayoutImporter.Build` call: the indicator column's backing panel (`0x10000600`) authors `PassToChildren=true` on its own empty DirectState (confirmed live: `States[0xFFFFFFFF].PassToChildren == true`), and `LayoutImporter.BuildWidget`'s post-attach state reapply (needed so retained PassToChildren TABS get their authored Open/Closed child media) cascades that DirectState to every `IUiDatStateful` child — including these already-correctly-resolved buttons. Retail's own decompiled `UIElement::SetState @0x00464e70` commits its `m_curStateDesc`/`m_state` unconditionally once `ElementDesc::AccessStateDesc` finds ANY StateDesc (media or not) and does the exact same blind per-child cascade; retail avoids this exact bug purely through construction TIMING — `UIElement::Initialize`'s `SetState(m_defaultState)` call is the SECOND operation in the function, before any child-tree construction, so a PassToChildren cascade fired during import always iterates zero children in retail. Our port's `LayoutImporter.BuildWidget` deliberately reapplies AFTER children are attached (the opposite order), so this literal 1:1 state-machine port needed a compensating guard rather than a full reapply-ordering rewrite (out of scope for this fix; `CharacterStatController`'s own three-chrome-children PassToChildren cascade depends on the current ordering and is left untouched). | `src/AcDream.App/UI/UiButton.cs` (`TrySetRetailState`'s `stateId == UiStateInfo.DirectStateId` branch) | Scoped to `UiButton` only — `UiDatElement.TrySetRetailState`'s parallel DirectStateId branch (and the cascade mechanism itself) are UNCHANGED, so every existing PassToChildren consumer keeps its current behavior; the fix only stops an UNRELATED ancestor's cascade from overriding a button's OWN already-resolved, independently authored state with an empty one it never asked for. | If a future button is EVER meant to render literally blank at rest via a cascaded DirectState with no authored `""` media, this guard would reject that transition (falls back to its previous `ActiveState`) — no such button is known to exist today; `UiButtonTests.DirectStateTransition_WithRealMedia_StillSucceeds` documents that an AUTHORED blank state still works. | `UIElement::SetState @0x00464e70` (cascade + unconditional commit); `UIElement::Initialize @0x00462c90` (SetState call precedes child construction) — both in `docs/research/named-retail/acclient_2013_pseudo_c.txt` | | AP-205 | **Filed 2026-08-11 at Campaign OP gate 4 (#381).** The Apply/Reset/Defaults footer on the Character/Chat/Config tabs draws an opaque, borderless backing field (`UiSolidSpriteFill`, tiling `RetailChromeSprites.CenterFill` — the SAME panel-background sprite the Options window's own `UiNineSlicePanel` chrome already tiles behind everything) behind the three buttons. A live-DAT probe (scratch console app against `DatCollectionAdapter`, 2026-08-11) found retail authors NO such element: each page root (`0x100001F9`/`0x100001FF`/`0x1000050A`) has EXACTLY five children — the row ListBox, its scrollbar, and the three physical buttons — with zero direct-state media on the root itself. Scrolled row content therefore bled through visibly between/behind the buttons before this fix. | `src/AcDream.App/UI/UiSolidSpriteFill.cs`; `src/AcDream.App/UI/Layout/OptionsPanelController.cs` (`AddFooterBacking`) | Reusing the SAME sprite the rest of the window's chrome already draws keeps the synthesized field visually indistinguishable from an authored one rather than inventing a new color; the field is `ClickThrough=true` and z-ordered strictly behind every other child, so it cannot intercept input or occlude the buttons themselves. | A reviewer comparing a byte-exact retail screenshot to acdream will see one extra opaque rect retail never authors — cosmetically invisible (it exactly matches the surrounding chrome), so the only observable difference IS the fix (content no longer bleeding through). If a future page's footer strip ever needs a DIFFERENT background (a themed panel, a translucent tab), this hardcoded `CenterFill` reuse would need revisiting. | Live-DAT probe, 2026-08-11 (page-root child-count/direct-state-media dump against `client_local_English.dat`, LayoutDescs `0x21000028`/`0x21000029`/`0x2100005C`) — no retail element to cite since none exists | | ~~AP-201~~ | **RETIRED 2026-08-11 at the Campaign OP gate-3 fix round (closes #371).** UiScrollablePanel now marks ClipsChildren=true (the draw walk and hit-test both route through UiRenderContext.PushClip, which existed by retirement time) and its cull predicate keeps any INTERSECTING row visible - a straddling row renders its visible slice instead of vanishing whole. The user-observed symptom this row predicted (the Chat tab per-window filter blocks reading as MISSING at the default scroll offset, gate 3) is the exact acceptance evidence. Original filing follows for the record: filed at the OP5 review-fix round (S2), predates OP5 but was made user-visible by it. `UiTemplateListBox`'s internal row viewport (`UiScrollablePanel.LayoutScrollableChildren`) culls a child WHOLE — `child.Visible = top >= -0.5f && top + child.Height <= Height + 0.5f` — rather than clipping the visible portion of a row that straddles the viewport edge, because the UI renderer has no scissor stack. Retail's own `UIElement_ListBox`/scroll-region rendering clips partially-visible rows at the pixel boundary, same as any native scroll view. Every row in this viewport was 8-36px until Campaign OP slice OP5 added five self-sized filter blocks (12x20=240px / 13x20=260px, AP-195) to the Chat tab's ~560px viewport; a 240-260px block straddling the viewport edge at a given scroll offset now disappears ENTIRELY (a visible "pop") instead of clipping, where the pre-OP5 8-36px rows made the same all-or-nothing cull read as ordinary row-granular scrolling. | `src/AcDream.App/UI/UiScrollablePanel.cs:69` (the cull predicate); consumed by `src/AcDream.App/UI/UiTemplateListBox.cs` (`Viewport`) — the Character/Chat/Config Options-panel tabs and any other controller-built row list sharing this viewport | A scissor stack does not exist anywhere in the retained-UI renderer yet (class's own doc comment, `UiScrollablePanel.cs:8-12`, predates this row); whole-row culling is a correct, cheap stand-in for every list whose rows are small relative to the viewport, which was true for every consumer before OP5. | A tall block (any future row taller than roughly the viewport's own height, not just OP5's filter blocks) can vanish completely for a range of scroll offsets instead of showing a partial view — the OP5 gate script's own step 2 documents the exact symptom so it is not mistaken for a self-sizing regression (`docs/research/2026-08-11-campaign-op-test-script.md`). Scrolling further always restores the block whole; no data or state is lost, only the presentation pops. | No scissor-stack retail oracle needed — this is a stand-in for ordinary native clip-rect rendering every GUI toolkit (including retail's own) provides; issue #371 tracks adding a real per-row clip rect to `UiScrollablePanel` | @@ -379,11 +394,22 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-190 | **Filed 2026-08-10 (Campaign CH slice CH6c — window opacity + transparency setting; retires AP-40). AMENDED 2026-08-10 at the CH6c review-fix round: reworded (2), added (3)/(4).** Four divergences from retail's focus-driven window opacity, all decomp-verified (`docs/research/2026-08-09-chat-retail-window-shell.md` §3). (1) SCOPE: retail's `ChatInterface::SetOpacity`/`SetDefaultOpacity`/`SetActiveOpacity` only ever run on `ChatInterface`-derived windows (the main chat window + the four floaties) — every other retail window (vitals, toolbar, inventory, ...) has no opacity fade at all. acdream's `RetailWindowOpacityController` subscribes to `RetailWindowManager.WindowRegistered` and applies the SAME focus-driven fade to every window the manager ever registers, so the one Settings → Chat tab transparency slider pair affects the whole retained UI. (2) DEFAULT VALUE — REWORDED at the review-fix round: retail's shipped defaults are PER WINDOW CLASS — the base `ChatInterface` ctor (`0x004F4550`) sets DefaultOpacity=0.5/ActiveOpacity=1.0, but `gmMainChatUI`'s own ctor (`0x004CD0F0`, called after the base ctor) overrides DefaultOpacity to 1.0 (the main window is ALWAYS fully opaque in both states); `gmFloatyChatUI::Create` (`0x004CE2C0`) calls the base ctor directly with no override, so only the four floating windows keep 0.5/1.0. acdream originally shipped the base ChatInterface value (0.5/1.0) as ONE shared global default applied to EVERY registered window — combined with (1)'s scope extension this faded the WHOLE registered UI (radar, vitals, toolbar, main chat, ...) to 50% opacity out of the box, including several windows that can never take keyboard focus at all and so were PERMANENTLY stuck at 0.5. Fixed at the review round to `gmMainChatUI`'s 1.0/1.0 override as the shared default instead: this reduces the remaining divergence to acdream's four floating chat windows shipping OPAQUE where retail's floaties ship 0.5-while-idle — user-settable via the same Settings → Chat opacity slider pair, so it is now a default-VALUE divergence only, not a missing mechanism. (3) EASING (new, filed at the review-fix round): retail's `ChatInterface::ListenToGlobalMessage @0x004F3840` — armed on the focus element-messages `0x1A`/`0x1E`/`0x28`/`0x29`/`0x2E` at `0x004F5275` via `UIListener::RegisterForGlobalMessage(this, 3)` — eases the live opacity toward its target by 5% of the target-delta per tick, unregistering from the global tick once within FP-epsilon of the target. acdream's `RetailWindowOpacityController.Apply` snaps to the target opacity immediately on every focus-change event; porting the per-tick lerp needs a UI frame-tick hook the controller does not have today, so it is deferred rather than implemented this round. (4) FOCUS PREDICATE (new, filed at the review-fix round): retail's `ChatInterface::IsTextEntryFocused @0x004F30A0` tests specifically whether `GetFocusDescendant(rootElement) == this->m_chatEntry` — the chat ENTRY FIELD, not the window generally. acdream's `RetailWindowHandle.DescendantFocusChanged` fires whenever ANY focusable descendant of the window gains focus, a strictly broader predicate for any window with more than one focusable child. The linked active>=default invariant itself (`SetDefaultOpacity`/`SetActiveOpacity`'s mutual-correction bodies) IS ported exactly — `ChatOpacityLink` in `AcDream.UI.Abstractions`. | `src/AcDream.App/UI/RetailWindowOpacityController.cs`; `src/AcDream.App/UI/RetailWindowManager.cs` (`WindowRegistered`); `src/AcDream.UI.Abstractions/Panels/Settings/ChatOpacityLink.cs`; `src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs` (`DefaultOpacity`/`ActiveOpacity`) | Extending the fade to every window is the shape the user's requested "transparency setting" actually wants (a general UI preference, not a chat-only one); shipping the shared default at 1.0 keeps the out-of-box render retail-identical for the 11 non-chat windows AND the main chat window (the windows retail keeps opaque, several of which can never take focus at all), while the Settings → Chat transparency slider remains fully user-settable for anyone who wants the four floaties' retail translucence back. (3) and (4) are both presentation-only refinements — the fade direction and the linked-invariant math stay retail-exact, only the transition curve (snap vs. 5%-per-tick ease) and the focus predicate's granularity (any descendant vs. the text-entry specifically) diverge — so recording them without implementing the frame-tick hook (3) or narrowing the focus event (4) is the correct scope for a review-fix round rather than opening new implementation work | A user who compares acdream's default install against retail side-by-side now sees the 11 non-chat windows AND the main chat window matching (opaque); only the four floating chat windows still diverge (opaque vs. retail's 50%-while-idle) until the slider is dragged. (3) is visible as the opacity change happening in a single frame instead of retail's ~20-tick fade — low severity, since the START and END states are both retail-exact, only the transition is instant instead of eased. (4) is visible on any window with more than one distinct focusable descendant (e.g. a settings panel with several controls): acdream stays at ActiveOpacity while ANY of them holds focus, where retail would already have faded back to DefaultOpacity once focus left the specific text-entry element — for single-focusable-child windows (most of the retained UI today) the two predicates coincide and there is no observable difference | `ChatInterface::ChatInterface @0x004F4550`; `gmMainChatUI::gmMainChatUI @0x004CD0F0`; `gmFloatyChatUI::Create @0x004CE2C0`; `ChatInterface::SetDefaultOpacity @0x004F3BC0`/`SetActiveOpacity @0x004F3C40`; `ChatInterface::ListenToGlobalMessage @0x004F3840`; `ChatInterface::IsTextEntryFocused @0x004F30A0`; global-message arming switch @0x004F5275 (`UIListener::RegisterForGlobalMessage(this, 3)` on element messages `0x1A`/`0x1E`/`0x28`/`0x29`/`0x2E`) | | AP-191 | **Filed 2026-08-10 (Campaign CH round 4, user-gate items 1+2 — retail two-plane glyph outline + authored SpewBox/chat text style, `docs/research/2026-08-10-retail-ui-text-style.md`).** The chat transcript's authored BASE STYLE (`0x10000372` in layout `0x2100003F`) carries a `0x1C`/`0x1D` pair alongside its `0x1A`/`0x1B` — `0x1D` (`TagFontColor[]`) is confirmed authored `ARGB(255,0,178,0)` (green), and `0x1C` is UNVERIFIED but most likely `TagFontDID` by symmetry with `0x1D` (both are pull-based, no `OnSetAttribute` case, unlike `0x1A`/`0x1B`/`0x21`/`0x22` which this round's commit DOES import). Retail's `AppendTextWithFont` selects a font/colour PAIR per appended run via `SetFontDIDNum`/`SetFontColorNum`, so a message's `[General]`-style channel tag can render in a distinct colour/font from the rest of the line — a capability `UiText.Line` does not have (one `Color` per whole line, no sub-line run concept). Landing this needs a per-run tag boundary threaded from `ChatTranscriptRenderer.BuildLines` through `UiText`'s line model into `UiRenderContext.DrawStringDat`, deliberately out of this round's scope (Fix 5 only changed the DEFAULT/uncolored-run seed, not the run model). `src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs` (`BuildLines`); `src/AcDream.App/UI/UiText.cs` (`Line`) | The default-fill fix (this same commit) is the higher-value, lower-risk half of retail's text-style gap for the transcript; a per-run tag concept is a larger structural change (touches the line model every transcript consumer reads) better landed as its own reviewed slice than folded into a text-style bugfix commit | Retail's `[General]`/channel-name tag prefix on a chat line renders the SAME colour as the rest of the line in acdream instead of green, and any authored tag-specific font goes unused — cosmetic only, the message text itself is unaffected | `UIElement_Text::AppendTextWithFont @0x00469de0`; `UIElement_Text::SetFontColorHelper @0x00466ac0`; `docs/research/2026-08-10-retail-ui-text-style.md` §2.3/§2.6 | | AP-192 | **Filed 2026-08-10 (Campaign CH round-5 polish, review item S2 — non-UiText outline paths).** Authored glyph outline `0x21`/outline color `0x22` now reach every text-bearing retained widget (`UiText`, `UiButton`, `UiDatElement`, `UiField`, `UiMeter`, `UiMenu`, `UiCatalogSlot` — the last two settable-only, having no authored build path), seeded ONCE from the element's effective-default state via `ElementReader.ApplyCanonicalLegacyProjection`'s `TryGetEffectiveProperty` (DirectState-then-effective-default rule). Retail instead re-resolves text properties on every UI STATE CHANGE — a button entering state `0x3` whose StateDesc authors `0x21=true` gains the outline for the duration of that state. The authored data hits this today: the dialog panel's two buttons (`0x2100003C` elements `0x17`/`0x19`), the character panel button `0x10000535`, and the combat panel button `0x100000B2` each author `0x21=true` in state `0x3` ONLY (DefaultStateId=1 → no outline at effective-default; `0x100000B2` also authors DirectState `0x21=true`, which the canonical rule DOES honor). The same seed-once shape already governs `UiText` (its `ApplyDatState` re-resolves `0x1B` FontColor per state but not `0x21`/`0x22`). `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (BuildButton/BuildCheckbox/BuildMeter/BuildText + the editable-field branch); `src/AcDream.App/UI/UiText.cs` (`ApplyDatState`) | Seed-once from the canonical effective state is strictly closer to retail than the pre-round-5 any-state first-wins scan (which lit those state-`0x3` outlines PERMANENTLY); the widening this row rides in on makes every ALWAYS-outlined authored element (DirectState/default-state authors) render retail-correct, and per-state re-resolution needs a property-application pass on the existing `TrySetRetailState` path — a reviewed slice of its own, not a polish-commit fold-in | A button that retail outlines only in a specific UI state (the four state-`0x3` authors above — state 3 is a hover/highlight-class state) never shows that transient outline in acdream; conversely nothing over-renders, since the effective-default resolution correctly yields outline-off for those elements | `UIElement_Text::SetOutline @0x0046a81c` (`m_bitField & 0x10`); `UIElement_Text::DrawSelf @0x00467aa0` (two-pass outline+fill); LayoutDesc fixtures `dialogs_2100003C.json` (`0x17`/`0x19`), `character_2100002E.json` (`0x10000535`), `combat_21000073.json` (`0x100000B2`) | +| AP-207 | **Filed 2026-08-15 at Campaign CC slice CC3 (character-creation state machine). ANCHOR CORRECTED at the CC3 review-fix round (F5) — the original citation (`gmCGProfessionPage::SetAttribValue @ 0x00482890`) does not call `FitTemplateToCharacter`; it only writes the raw attribute via `SetStrength`/`SetEndurance`/etc. then calls `gmCGProfessionPage::UpdateAttributeValues`, which is one of the real call sites below.** Retail re-detects the closest-matching Profession template on every attribute-slider edit (`CharGenState::FitTemplateToCharacter @ 0x005C6130`, called from FOUR real sites: `gmCGProfessionPage::UpdateAttributeValues @ 0x00482450` (call at `0x004827F4`), `gmCGProfessionPage::Update @ 0x00482830` (call at `0x00482840`), `gmCGProfessionPage::UpdateToDefaultAttributes @ 0x00482860` (call at `0x00482875` — a fourth site the original filing also missed), and `gmCGSummaryPage::Update @ 0x0047BAA0` (call at `0x0047BB63`)), auto-flipping `template_` to whichever preset the current attribute+skill spread scores closest to (or to `0xFFFFFFFF`/"no match" when nothing fits within tolerance) via an FPU-heavy weighted-distance heuristic (`TEMPLATE_WEIGHT_ATTRIBUTES`/`_TRAINED_SKILLS`/`_SPECIALIZED_SKILLS`). Several of the function's float operations are literally unrecoverable in the named decomp (`/* unimplemented {fild/fidiv/fmul/fadd ...} */` markers Binary Ninja could not translate), consistent with this project's existing x87-blocked precedent. `RuntimeCharacterCreationState` never re-derives `Template` from attribute/skill edits — it only changes via an explicit `SelectTemplate` command, matching `SetTemplate @ 0x005C5A60`'s own commit path. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TrySetAttribute`, `TrySetSkillLevel` — neither calls a `FitTemplateToCharacter` port) | ACE's `PlayerFactory.CreatePlayer` only reads `TemplateOption` for the character's display title/name text (`references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:135-138`) — it never re-validates attributes/skills against the named template, so a stale `Template` value has no server-side consequence; porting an FPU-unrecoverable heuristic for a value ACE ignores is not a good trade. | A free-editing user who drifts away from their chosen template's exact spread keeps seeing that template's name/button highlighted instead of retail's live re-detection (which might silently flip to a different preset name, or to "Custom"); this is presentation-only until CC4/CC5 build the Profession page's button highlight. | `CharGenState::FitTemplateToCharacter @ 0x005C6130`; `gmCGProfessionPage::UpdateAttributeValues @ 0x00482450`; `gmCGProfessionPage::Update @ 0x00482830`; `gmCGProfessionPage::UpdateToDefaultAttributes @ 0x00482860`; `gmCGSummaryPage::Update @ 0x0047BAA0`; `CharGenState::SetTemplate @ 0x005C5A60`; `PlayerFactory.cs:135-138` | +| AP-208 | **Filed 2026-08-15 at Campaign CC slice CC3.** Retail derives a PER-STYLE available-dye-color count for each clothing slot via `CharGenState::StoreColorInformation @ 0x005C44D0` (reading that specific style's own `ClothingTable`/`CloPaletteTemplate` palette list — different headgear styles can offer different numbers of dye choices) and clamps `headgearColor`/`shirtColor`/`trousersColor`/`footwearColor` against that per-style count in `SetHeadgearStyle`/`SetShirtStyle`/`SetTrousersStyle`/`SetFootwearStyle` (@0x005C5350/0x005C5480/0x005C55A0/0x005C56C0) and `ConstrainAllByGender @ 0x005C5B80`. `ChargenOptions`/`ChargenGenderOptions` (CC1) carry no per-style color-count data — only ONE shared `ClothingColors` list per gender. `RuntimeCharacterCreationState.TrySetAppearanceIndex`/`ConstrainAppearanceByGenderLocked` bound every color slot against that single shared list instead. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`AppearanceSlotCountLocked`, `ConstrainAppearanceByGenderLocked`) | Adding per-style color-count data to CC1's Core model requires a new DAT read (`CloPaletteTemplate`/`Style_CG` palette-template walk) that CC1's already-review-closed `ChargenTableReader` doesn't perform; the shared-list bound is a safe (never-narrower-than-necessary in the common case) stand-in until a future slice reads the real per-style table. | A clothing style whose real per-style color count is SMALLER than the shared gender-wide `ClothingColors` list lets the user pick a color index retail would have refused for that specific style — the resulting wire index may resolve to a different (or no) dye on a genuine retail-DAT-driven ACE/appearance consumer. | `CharGenState::StoreColorInformation @ 0x005C44D0`; `SetHeadgearStyle @ 0x005C5350`; `ConstrainAllByGender @ 0x005C5B80` | +| AP-209 | **Filed 2026-08-15 at Campaign CC slice CC3. BRANCH TABLE ADDED at the CC3 review-fix round (F10) — the original filing cited only the ordinary-human enum id, omitting the heritage-dependent branches.** Retail's `classID` wire field is resolved via `DBObj::GetDIDByEnum(...) @ CharGenState::GetCharGenResult 0x005C4030` — a DAT DID category lookup that branches on THREE heritage-dependent enum ids (`0x005C42B5`-`0x005C438B`): `0x10000003` for ordinary heritages, `0x10000090` for Olthoi (heritage `0xc`), `0x10000091` for OlthoiAcid (heritage `0xd`), plus three admin-flag variants of the same three (`0x10000004`/`0x10000092`/`0x10000093`) when the create is admin-flagged. `AcDream.Core` has no DAT/Chorizite dependency (a CC1-established, review-closed constraint), so `RuntimeCharacterCreationState.BuildRequestLocked` sends a constant `0` regardless of heritage. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`BuildRequestLocked`) | ACE's `PlayerFactory.CreatePlayer` never reads `characterCreateInfo.ClassId` (`references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:155`, commented out) — the field has no observable server-side effect against the only connected target this campaign gates on. | A future non-ACE server that DOES validate `classID` would reject or misclassify every acdream-created character; a future slice that wires the real DID lookup must NOT default to the ordinary-heritage id for Olthoi/OlthoiAcid characters — this row is the marker (and the branch table) to revisit if that ever becomes a real target. | `CharGenState::GetCharGenResult @ 0x005C4030` (branch table `0x005C42B5`-`0x005C438B`); `DBObj::GetDIDByEnum`; `PlayerFactory.cs:154-155` | +| AP-210 | **Filed 2026-08-15 at Campaign CC slice CC3.** Retail's `ApplyTemplate @ 0x005C5080` applies a chosen template's six attributes one at a time through the individually-guarded setters (`SetStrength(this, row.strength, 0)` … `SetSelf(this, row.self, 0)`), each of which can silently refuse to RAISE its value when `GetAbsRemainingCredits` for that specific attribute is exactly zero at the moment it runs — a narrow but real cross-attribute ordering effect when switching heritage/template leaves stale attribute values from a PRIOR selection still resident during the sequential apply. `RuntimeCharacterCreationState.ApplyTemplateLocked` instead assigns `_attributes = row.Attributes` as one atomic replacement. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`ApplyTemplateLocked`) | Every template row in the installed CharGen DAT is curated, self-consistent data (CC1's installed-DAT gates), so the guard is not expected to trip for any real heritage/template pair in isolation; the ordering effect only matters when switching directly between two heritages/templates with very different attribute totals, which is a corner case not yet gated by a connected test. | A rapid heritage-switch-then-template-switch sequence could theoretically leave an attribute at a value retail's sequential guard would have refused to reach; unreachable through this slice's own commands (heritage selection always re-derives the FULL budget before applying), but a future direct-attribute-manipulation caller bypassing `TrySelectHeritage`/`TrySelectTemplate` could differ from retail. | `CharGenState::ApplyTemplate @ 0x005C5080`; `CharGenState::SetStrength @ 0x005C4660` (representative of all six) | +| AP-215 | **Filed 2026-08-15 at Campaign CC slice CC6b-MOUNT (Appearance page visual substitutions); NARROWED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-9) — item 1 (the swatch-selection substitution) RETIRED; RE-NARROWED 2026-08-16 at Batch C fix (GF-6/AP-218) — the "1-based ordinal" framing of item 2 is now STALE and replaced below.** What CLOSED at Batch B: the nine color swatches (`0x1000030f-0x10000317`) now drive the SAME companion overlay elements retail's own `SetColor @0x0047DD50` toggles (`m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) — `CharacterCreationAppearancePage.RefreshColorAndShadeControls` shows exactly the overlay (`0x10000318-0x10000320`, `SwatchOverlayIds`) at the currently-selected color index and hides the rest. What CLOSED at Batch C: `SetStyleSpinLabel`'s 1-based-ordinal substitution is GONE — `RefreshSpinCaptions` now writes retail's own heritage-flavored STATIC caption (see AP-218, RETIRED). **Still open (RESTATED, not the same gap the ordinal covered):** the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name string) now show the SAME static caption regardless of which style is selected — retail's own per-choice visual feedback there is an ICON THUMBNAIL this port still doesn't render (no icon-texture pipeline is wired to ANY chargen widget); the live 3D preview is the player's only feedback for which style is currently active. The four clothing spins (headgear/shirt/trousers/footwear) show a real name via `ChargenGearOption.Name` and have no icon gap. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s overlay loop, CLOSED Batch B; `RefreshSpinCaptions`, static-caption-only, icon gap still open) | An icon-texture pipeline for the four icon-only spins is new UI infrastructure this round's scope doesn't otherwise need; the static caption alone is retail-faithful for the TEXT half. | A pixel-level side-by-side against retail would show no icon thumbnail next to the four icon-only spins' caption (cosmetic gap only — the caption text itself is now byte-correct, and the live 3D preview still shows the actual selection). A future icon-rendering pass (if chargen ever needs one, e.g. for the heritage/template icons too) would naturally close this row. | `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip`/`ChargenGearOption` (CC1, `src/AcDream.Core/CharGen/ChargenAppearanceOptions.cs`) | +| AP-219 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 6).** Retail's `gmCGAppearancePage::Update` repositions the Skin spin vertically when Nose/Mouth are hidden, closing the gap those two spins would otherwise leave: `m_pSkinSpin->MoveTo(0, 0x5a)` (Y=90) for Olthoi/OlthoiAcid (`@0x0047edef`) and Gearknight (`@0x0047ea83`), vs `MoveTo(0, 0xb4)` (Y=180) for every other heritage (`@0x0047ec41`). acdream hides Nose/Mouth (`Refresh`'s `clothesHidden` branch) but never repositions Skin, leaving a visible vertical gap in the Face tab's spin list for these three heritages. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh`'s `clothesHidden` branch — hides Nose/Mouth, never moves Skin) | The spins are laid out via their authored LayoutDesc positions (`DatWidgetFactory`), which this campaign's slice doesn't runtime-reposition for any other case; the targeted behavior this round was visibility (hiding unreachable spins), not repositioning the ones that remain. | A side-by-side against retail on Olthoi/OlthoiAcid/Gearknight shows a visible vertical gap where Nose/Mouth used to sit, instead of Skin sliding up to close it — a layout/cosmetic gap, not a functional one. | `gmCGAppearancePage::Update` `MoveTo` calls `@0x0047edef` (Olthoi/OlthoiAcid), `@0x0047ea83` (Gearknight), `@0x0047ec41` (every other heritage, the "normal" position) | +| AP-220 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 7); tightened 2026-08-15 at the re-review of fix commit `d2a71152` (N1) — "leaving Gearknight for something else" over-claimed the exit side.** Retail's `gmCGAppearancePage::Update` calls `CharGenState::RandomizeAppearance(state, 0)` + `CharGenState::RandomizeClothing(state, 1)` exactly once, on the SPECIFIC frame the heritage crosses the Gearknight boundary in either direction — entering Gearknight from something else (`@0x0047e973`, gated on `m_LastHeritageGroup != 6`) or leaving Gearknight for a non-Olthoi heritage (`@0x0047eb58`, gated on `m_LastHeritageGroup == 6` inside the `else` arm of the `mHeritageGroup == 0xc || mHeritageGroup == 0xd` Olthoi/OlthoiAcid test `@0x0047eb46` — leaving Gearknight FOR Olthoi or OlthoiAcid takes the Olthoi-specific `if` arm instead and does NOT randomize). acdream's `Refresh` (the `Update` analogue) has no heritage-transition-edge tracking at all and never calls anything on a Gearknight-boundary crossing. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh` — no `_lastHeritageId`-style transition tracking or randomize call) | This is the SAME six-primitive gap AP-212 (the Random button) and AP-214 (ctor-time `RandomizeCharacter`) already track — `RandomizeAppearance`/`RandomizeClothing` are two of AP-212's six named-but-unported `CharGenState` primitives; a THIRD call site for the identical missing primitives doesn't widen the underlying gap, just where it's also reachable. | Switching heritage into or out of Gearknight in acdream leaves the character's prior appearance/clothing selections untouched (whatever indices were already set, now possibly out-of-range and silently clamped by `ConstrainAppearanceByGenderLocked` rather than freshly randomized), where retail re-rolls both — a behavioral gap a connected gate switching heritage to/from Gearknight would observe directly. | `gmCGAppearancePage::Update` `@0x0047e973` (entering Gearknight) and `@0x0047eb58` (leaving Gearknight); `CharGenState::RandomizeAppearance @0x005c4f10`; `CharGenState::RandomizeClothing @0x005c6770` (both already cited by AP-212) | +| AP-221 | **Filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (R2) — records the F8 one-shot-binding disposition the re-reviewer accepted as a scoped, documented call, but which shipped without a register row of its own. AMENDED at the CC5 review-fix round, F7 (2026-08-16): this row's own "Risk" column named CC5 as the slice that "should close" this gap; CC5 instead DUPLICATED the same one-shot pattern for a second private viewport (the Summary preview) rather than closing it, and the duplicate shipped without extending this row to cover it — corrected below.** The chargen Appearance-page preview's GPU-side renderer/viewport binding in `LivePresentationComposition`'s chargen block reads `RetailUiRuntime.ChargenPreviewViewportWidget` exactly ONCE, synchronously, during the single `GameWindow.OnLoad` composition pass. `ChargenPreviewViewportWidget` is computed-through `CharacterCreationUiMountCoordinator`, which IS explicitly retryable/idempotent — ticked once per frame (via `RetailUiRuntime.Tick`) until its own DAT/resource read succeeds. If the coordinator's synchronous construction-time mount has NOT succeeded by that one composition pass (DATs not readable on that exact frame), the coordinator's later per-frame retries can still restore the rest of the mounted chargen SCREEN, but this GPU-side lease/binding is never retried — the preview stays permanently unbound for the rest of the session: no lease acquired, no renderer assigned to `chargenViewport`, `RetailUiRuntime.ChargenPreviewControl` never set, and the Appearance page's zoom/rotate controls silently no-op for the whole session. The narrowed diagnostic added at R1 (this same commit) is the only operator-visible evidence, and only fires when retained UI is actually mounted. **The Summary preview block (CC5, immediately below the Appearance block in the same method) is the SAME shape against a SECOND independent lease/binding pair (`summaryPreviewLease`/`summaryPreviewController`, `RetailUiRuntime.SummaryPreviewViewportWidget`/`SummaryPreviewControl`) — a DAT/resource miss on that one composition pass leaves the Summary page's 3D preview permanently unbound for the session with only its own narrowed `Console.WriteLine` diagnostic as evidence (no zoom/rotate controls to lose there, since retail's own Summary viewport has none — see `RetailSummaryPreviewPageVisibility`'s doc comment — but the idle-animated preview itself never renders).** | `src/AcDream.App/Composition/LivePresentationComposition.cs` (the chargen preview viewport block, the `if (dispatcherLease.Resource is { } chargenDispatcher && interaction.RetainedUi?.Runtime.ChargenPreviewViewportWidget is { } chargenViewport)` arm and its `else if` diagnostic, plus the Summary preview block's identical `summaryDispatcher`/`SummaryPreviewViewportWidget` arm immediately after it); `src/AcDream.App/UI/RetailUiRuntime.cs` (`ChargenPreviewViewportWidget`, `SummaryPreviewViewportWidget`); `src/AcDream.App/UI/Layout/CharacterCreationUiMountCoordinator.cs` | Retrofitting cross-frame retry into this one binding would mean restructuring the whole composition's one-shot GPU-resource-wiring contract shared by paperdoll (`PaperdollViewportWidget`), creature-appraisal, AND now the Summary preview in the SAME method, plus the fixed `PrivateEntityViewportFrameGroup` array `FrameRootComposition` builds from the result — out of both the CC6b-MOUNT fix round's AND CC5's blast radius; each round accepted the narrower diagnostic-only fix as sufficient, with this row as the tracked follow-up for BOTH bindings now. | On the specific unlucky frame where either coordinator's construction-time `Tick()` has not yet succeeded (a DAT/resource read not ready that frame), a user gets a chargen screen that otherwise mounted fine but whose Appearance 3D preview zoom/rotate controls, OR whose Summary 3D preview entirely, is dead for the ENTIRE session with no visible error beyond the respective narrowed console diagnostic — a session-permanent, hard-to-reproduce loss a future retry-aware rewrite of BOTH bindings should close together (a single fix, not two). | `src/AcDream.App/Composition/LivePresentationComposition.cs:1001-1109` (chargen preview block's own F8 disposition comment) and `:1111-1185` (the Summary preview block, same disposition, referencing this row); `RetailUiRuntime.ChargenPreviewViewportWidget`/`SummaryPreviewViewportWidget`'s doc comments (retry-vs-one-shot contrast) | +| AP-212 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Random button, element `0x100003cb`); primitives named+cited in the review fix round (F8, 2026-08-15). NARROWED 2026-08-15 at Campaign CC slice CC5 — Appearance and Summary CLOSED.** `gmCharGenMainUI::DoRandom @ 0x004e7d70` switches on the current page and dispatches to six NAMED, fully decompiled retail primitives, one per page: Heritage -> `CharGenState::RandomizeHeritageGroup(state, hasToD) @ 0x005c6a20`; Profession -> `CharGenState::RandomizeTemplate(state) @ 0x005c6500`; Skills -> `CharGenState::RandomizeSkills(state) @ 0x005c57e0`; Appearance -> `CharGenState::RandomizeAppearance(state, 0) @ 0x005c4f10` or `CharGenState::RandomizeClothing(state, 1) @ 0x005c6770`; Town -> `CharGenState::SetStartArea(state, RandInt(hasToD ? 4 : 3))`; Summary -> `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80`. CC5 ports the Appearance/Summary primitives faithfully into `RuntimeCharacterCreationState` (`RandomizeAppearanceLocked`/`RandomizeClothingLocked`/`RandomizeCharacterLocked`, exposed as `TryRandomizeAppearance`/`TryRandomizeClothing`/`TryRandomizeCharacter`) and wires both pages' Random buttons to them — those two gaps are CLOSED, not approximated. **Still open:** Heritage/Profession/Town's Random handlers still use CC4's UNIFORM pick over every valid option (not `RandomizeHeritageGroup`'s hasToD-bounded roll, `RandomizeTemplate`'s exclude-current-preset roll, or `SetStartArea`'s literal 3/4 bound) — narrowing those three was not in CC5's scope; Skills' Random stays hard-disabled (`RandomizeSkills` remains unported). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`OnRandom`, `ApplyProgressState`'s `_random.Enabled` gate); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Randomize`, CC5 — real primitive, retired from this row); `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (CC5's Randomize section) | Random is a convenience affordance, not a gate any create can fail without — every value it can produce is independently reachable (and independently retail-cited) through the page's own ordinary Select commands; a uniform distribution over "every DAT-installed option" is the closest available stand-in for the THREE remaining pages without porting three more retail algorithms this round did not scope (Heritage/Profession/Town's own roll algorithms, now the only ones left). | A retail-parity test that checks the STATISTICAL distribution of repeated Random clicks on Heritage/Profession/Town would find acdream's uniform-over-all-options distribution differs from retail's own (e.g. `RandomizeTemplate`'s exclude-current-preset weighting, or the ToD-account-gated 3-vs-4 town bound — see AD-102); Appearance/Summary now match retail's real distribution exactly (RandInt/RollDice ported verbatim). Skills has no Random affordance at all until `RandomizeSkills` lands. | `gmCharGenMainUI::DoRandom @ 0x004e7d70`; `CharGenState::RandomizeHeritageGroup @ 0x005c6a20`; `CharGenState::RandomizeTemplate @ 0x005c6500`; `CharGenState::RandomizeSkills @ 0x005c57e0`; `CharGenState::SetStartArea` random-bound call site | +| AP-211 | **Filed 2026-08-15 at the Campaign CC slice CC3 review-fix round (F12). Updated 2026-08-16 at Campaign CC slice CC7** — the row's own predicted resolution has now happened; text corrected rather than retired (see below). `RuntimeCharacterCreationState.TryBeginFinish` refuses locally (`RuntimeCharacterCreationLocalRefusal.RosterFull`) when `rosterCount >= slotCount`, gating a Finish attempt against the account's CharacterSet slot cap. `gmCharGenMainUI::DoFinish @ 0x004E9170` itself has NO such check — the decomp shows only the name/credit/verification-state gates (see the row's own doc comment history). Retail instead enforces the slot cap ONE LAYER UP, in the char-select UI that ghosts/un-ghosts the Create button (`gmCharacterManagementUI::UpdateButtons @ 0x004ec240`, ~0x004ec319-0x004ec32e: `_charSet.set_.m_num < _charSet.numAllowedCharacters_`) — CC7 ported that exact gate into `RuntimeCharacterSelectionButtons.CanCreate` (`RuntimeCharacterSelectionState.BuildButtons`) and wired `CharacterManagementUiController`'s Create button to it, closing the citation gap this row previously left open. ACE never checks the cap server-side either way. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TryBeginFinish`, `RuntimeCharacterCreationLocalRefusal.RosterFull`); `src/AcDream.Runtime/Session/RuntimeCharacterSelectionState.cs` (`CanCreate`, CC7's retail-cited gate); `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (Create's `Enabled` binding, CC7) | Both layers are now intentionally KEPT, matching this row's own prediction: the Create-button gate reproduces retail's real enforcement point for the ordinary UI path, while `TryBeginFinish`'s own refusal remains defense-in-depth for any caller that reaches Finish without going through that button (a headless bot, a future scripted client, or a UI bug that lets Finish fire while stale) — exactly the residual case the row's own risk column called out. | None remaining for the ordinary UI path (both layers now agree with retail's real enforcement site); a caller that bypasses the Create-button gate entirely still hits `TryBeginFinish`'s own refusal, which has no direct `DoFinish` citation (by design — retail's OWN `DoFinish` never checks this, only its UI layer does). | `gmCharGenMainUI::DoFinish @ 0x004E9170` (no slot-cap check present); `gmCharacterManagementUI::UpdateButtons @ 0x004ec240` (the retail enforcement site, now ported); `docs/plans/2026-08-15-character-creation-campaign.md` (Risks item 3) | -## 4. Temporary stopgap (TS) — 48 active rows (TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) +## 4. Temporary stopgap (TS) — 49 active rows (TS-82 RETIRED 2026-08-15 at Campaign CC slice CC5 — the Summary page is now fully built (name field with NameInputFilter, the three-template listbox, its own live-idle-animated `gmCG3DView` preview, and the Finish gate's real UI), closing the last placeholder this row tracked (narrowed to Summary-only at CC6b-MOUNT after the Appearance page landed); TS-83 RETIRED 2026-08-15 at Campaign CC slice CC6b (pre-mount half) — the chargen 3D preview now plays retail's live 30fps idle loop (`ChargenPreviewAnimator`, `RetailAnimationCyclePlayback`) by default, exactly matching the decomp-verified finding that `gmCGAppearancePage::Update`'s own trailing gate calls `StartAnimation` whenever `m_bZoomedIn == 0` — CORRECTED at the same-round review (F1): the original filing argued this from the ctor never touching `m_bZoomedIn`, an unsound "elided/uninitialized byte" inference (heap `operator new` memory is indeterminate, not zero); the real, sound evidence is `gmCGAppearancePage::InitializePage @ 0x0047FDD0`'s EXPLICIT `this->m_bZoomedIn = 0;` at `0x004802C3`, written immediately after that same function sets the camera to the zoomed-IN per-heritage eye (`0x00480286-0x0048029E`) — a genuine retail quirk this implies: the character starts framed close-up AND not-zoomed-in at the same time, so the FIRST Zoom In click tweens close-eye→close-eye (visually null) while still freezing the animation, which the port reproduces faithfully — and only freezes to the held rest pose once the (not-yet-mounted) Zoom In button fires; the row's own citation "CreatureMode::set_sequence_animation... not yet located precisely" is resolved: the actual mechanism is `CPhysicsObj::set_sequence_animation @ 0x0050F6F0` called from `gmCG3DView::StartAnimation @ 0x004EE600` with a constant 30fps DID and no further motion traffic, which CC6b reproduces via a shared, Core, unit-tested advance-with-wrap-then-lerp/slerp primitive; TS-84 filed 2026-08-15 at Campaign CC slice CC6a (renumbered from its branch-local TS-82 at the CC6b-PRE merge: the CC4 branch independently allocated TS-82 for the Appearance/Summary placeholder pages, and landed first), corrected at the same-session review fix round (F2/F7) — the chargen 3D preview's un-ported `ClothingTable::BuildObjDesc` Setup-substitution chain, measured (not assumed) and now PINNED by a real assertion to leave Undead's default preview unclothed on ALL FOUR clothing slots (not three); TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| TS-84 | Chargen 3D preview (Campaign CC slice CC6a foundation): `ChargenClothingTable`'s composer skips retail's ~8-branch Setup-id substitution chain (`ClothingTable::BuildObjDesc @ 0x005A7900`'s Umbraen/Penumbraen/Undead/Anakshay fallback) when a garment's `ClothingBaseEffects` has no entry for the resolved body Setup. MEASURED (not assumed) against the installed EoR dat across all 26 heritage/gender combinations via `ChargenAppearanceCatalogInstalledDatTests`, with the measurement now PINNED by a real assertion rather than diagnostic-only output (review fix round F7): the 9 standard heritages whose UI actually shows clothing controls resolve every default gear choice with zero coverage gaps. Undead is a real gap — its default gear choices (both genders) have NO base-effect entry on **ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear** (not the three-slot "headgear/trousers/footwear" this row originally understated, with a self-contradicting "4 of 4 non-shirt slots" aside — corrected at the review fix round F2) — for Undead's own live body Setup (male 0x02001A9C / female 0x02001AA0), because that Setup is one of the skeleton/zombie variants the un-ported chain exists to redirect. The four measured missing clothing-table ids are identical on both genders and in a fixed order: `0x10000009, 0x100000F9, 0x10000001, 0x10000007` (Headgear, Trousers, Shirt, Footwear — the factory's own composition order). Gear Knight and both Olthoi variants also show gaps under a synthetic "select every offered option" sweep, but retail hides the clothing controls entirely for those three heritages (`gmCGAppearancePage::Update @ 0x0047E8F0`'s `m_pClothesButton->SetVisible(0)` branches for `mHeritageGroup == 6` and `== 0xc \|\| == 0xd`), so a real chargen selection never reaches them — not a live gap. | `src/AcDream.Core/CharGen/ChargenClothingTable.cs`; `src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs` (`ComposeClothingSlot`) | CC6a is explicitly the rendering-foundation slice (index→ObjDesc factory + static-pose offscreen renderer, no page mount yet); porting the ~8-branch substitution chain is bounded follow-up work once CC6b wires real clothing-slot UI, not a blocker for the foundation deliverable — and the installed-DAT test proves the gap is narrow (one heritage, all four of ITS slots) rather than pervasive. | Undead's default clothing preview renders the bare body mesh for ALL FOUR slots — headgear, trousers, shirt, AND footwear (no clothing part/texture override applied on any of them, though the dye subpalette contribution — gated on a DIFFERENT lookup — is unaffected) — until the chain, or an equivalent per-heritage default-clothing-Setup map, is ported. | `ClothingTable::BuildObjDesc @ 0x005A7900` (Umbraen/Penumbraen/Undead/Anakshay Setup-substitution branches); `gmCGAppearancePage::Update @ 0x0047E8F0` (clothes-button visibility gate); `tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs` | | TS-73 | **NARROWED 2026-08-11 at Campaign OP slice OP4.** `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged @0x0059A8E0`'s local side-effect switch (step 2) still covers only the two `PlayerModule`-state-mutating cases (`case 2`/`case 0x12` fellowship mutual exclusion) — that part is unchanged. Of the four presentation-binding cases, TWO are now closed: `0x07 ViewCombatTarget` (re-pointed `ICombatGameplaySettingsSource` reads `RuntimeCharacterOptionsState` live — `CharacterOptionCombatSettingsSource`, `src/AcDream.App/Combat/LiveCombatAttackOperations.cs`) and `0x30 DisableDistanceFog` (`WeatherSystem.DisableDistanceFogSource`, a poll bound once in `GameWindow.cs`, forces `FogMode.Off` in `WeatherSystem.Snapshot`) — NEITHER lives inside `TrySetOption`'s own switch; both are separate App-layer poll bindings, so the literal claim in this row's title ("this Runtime-only seam can reach") stays true, but the user-observable symptom is fixed for these two ids. The remaining two, `0x04 DisableMostWeatherEffects` and `0x05 PersistentAtDay`, stay open — see TS-6 (weather-particle subsystem not yet located) and TS-75 (day/night force) respectively; this row no longer duplicates either. | `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs` (`RuntimeCharacterOptionsState.TrySetOption`) | The remaining two options are correctly scoped to their OWN pre-existing/new rows (TS-6, TS-75) rather than re-litigated here. | Toggling `DisableMostWeatherEffects`/`PersistentAtDay` writes the bit and dirties/auto-saves it correctly, but produces NONE of retail's immediate local presentation change (weather doesn't stop, day/night doesn't force) — see TS-6/TS-75 for why. `ViewCombatTarget`/`DisableDistanceFog` are retired from this row's risk: both now behave correctly. | `CPlayerModule::OnChanged @0x0059A8E0`; `docs/research/2026-08-10-character-options-map.md` §1.5 | | TS-75 | "Always Daylight Outdoors" (`PlayerOption PersistentAtDay`, `CPlayerModule::OnChanged` case `0x05` → `LScape::SetDay(value)`) has no acdream consumer. The campaign plan's own Group-B binding table cites `RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` as the target seam — **that citation is a mechanism mismatch, corrected here**: `ForcedDayGroupIndex` selects which WEATHER-VARIETY day-group (`RuntimeWorldDayGroupDefinition`, e.g. a Clear/Overcast/Rain/Snow/Storm pick) is always chosen — the SAME deterministic-per-day-RNG mechanism `WeatherSystem`'s own roll uses (see TS-6) — NOT retail's time-of-day day/night force. No acdream mechanism currently overrides the sky cycle's TIME to stay in daytime lighting; wiring this option correctly needs that mechanism built first, not just a poll into the wrong field. | `src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs` (`RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` — NOT the right target); no current consumer exists | Filed rather than silently wired to the wrong field — a poll into `ForcedDayGroupIndex` would have SILENTLY changed the character's weather-variety odds instead of forcing daytime, an incorrect fix masquerading as a correct one (CLAUDE.md's "no workarounds" rule). | Toggling the option writes the bit and dirties/auto-saves it correctly, but night still falls normally — no observable daylight-forcing behavior. | `CPlayerModule::OnChanged @0x0059A8E0` case 5; `LScape::SetDay` (not yet located in the decomp) | | TS-76 | Five Character-tab rows have no acdream consumer at all (research doc §4.2's own "state-only, no consumer" list, narrowed to the ids NOT already closed by Campaign OP's Group-C re-points): "Display 3D Tooltips" (`ShowTooltips`), "Side By Side Vitals" (`SideBySideVitals`), "Display Spell Durations" (`SpellDuration`), "Advanced Combat Interface" (`AdvancedCombatUI`), "Stay in Chat Mode After Sending a Message" (`StayInChatMode`) — retail renders 3D item tooltips, an alternate side-by-side vitals layout, remaining-duration overlays on enchantment icons, an expanded combat panel, and a chat-input-stays-open behavior respectively; acdream has none of the four rendering surfaces and no chat-input-close-on-send behavior to gate in the first place. | `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs` (the rows wire+store only) | Each needs a real UI/behavior feature built before the option means anything — inventing a stand-in now would be exactly the workaround CLAUDE.md forbids. | Toggling any of the five writes the bit and dirties/auto-saves it correctly, but no observable client behavior changes. | `gmGamePlayUI::RecvNotice_PlayerOptionChanged @0x004e9da0`; `EffectInfoRegion::Update @0x004f1c00`; `gmCombatUI::RecvNotice_SetCombatMode @0x004cc620`; `ChatInterface::HandleEnterKey @0x004f52d0`; `UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004e5ad0` | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index c4fd0cf9..4f2cc180 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -1,6 +1,6 @@ # acdream — strategic roadmap -**Status:** Living document. Updated 2026-08-03. **M3 landed; M4 is active.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. All eight slices of the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md), their automated closeout, and the user's connected visual matrix are complete. Modern Runtime J3 canonical entity/object lifetime and J4 gameplay-state ownership are closed at `89e6b207`; J5.1 canonical selection/combat/target-mode ownership is closed at `b298f99f`, J5.2 interaction transactions at `f5f7b417`, J5.3 combat/magic intent at `20df9d15`, J5.4 local movement/outbound cadence at `aa3f4a60`, J5.5 per-session physics/remote simulation at `7e6033d0`, J5.6 projectile simulation at `2aee3356`, and J5.7 combined simulation closeout at `cdee7a4b`. J6.1 world-environment ownership is closed at `902076c0`; J6.2 canonical reveal generation and typed destination readiness is closed at `a6860d55` plus `acb845d8`; J6.3 exact F751/Position destination correlation is closed at `6a063a27`; J6.4 exact graphical-host acknowledgement and owner cleanup is closed at `18d17d8b`. J7's one graphical `GameRuntime` root is closed at `ce41efb9`, including the user's 2026-07-27 exact post-cutover visual acceptance. J8 closed Slice J at `a9a822f2` with one shared graphical/no-window root and generation-reset transaction. Slice K Linux headless/multi-session work is closed. K0's tested no-presentation Windows/Linux boundary closed at `aada8a37`, K1's portable single-session host at `f8cb840f`, K2's deterministic scheduler and shared bot API at `7e8acb74` plus `38e83640`, and K3's shared-content/isolation plus connected observer gate at `3f340125`. K4 closed through `776482da`: 1/5/10/30-root isolation and two-hour simulated endurance, death/randomized cancellation, committed resource ceilings, ten minutes of exact native Linux two-account connected sampling, ACE-confirmed graceful logout, and zero-debt Runtime/content convergence all pass. Slice L Linux graphical/platform work is parked at its L1 implementation checkpoint by user direction on 2026-07-27. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. +**Status:** Living document. Updated 2026-08-14. **M3 landed; M4 is active.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. All eight slices of the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md), their automated closeout, and the user's connected visual matrix are complete. Modern Runtime J3 canonical entity/object lifetime and J4 gameplay-state ownership are closed at `89e6b207`; J5.1 canonical selection/combat/target-mode ownership is closed at `b298f99f`, J5.2 interaction transactions at `f5f7b417`, J5.3 combat/magic intent at `20df9d15`, J5.4 local movement/outbound cadence at `aa3f4a60`, J5.5 per-session physics/remote simulation at `7e6033d0`, J5.6 projectile simulation at `2aee3356`, and J5.7 combined simulation closeout at `cdee7a4b`. J6.1 world-environment ownership is closed at `902076c0`; J6.2 canonical reveal generation and typed destination readiness is closed at `a6860d55` plus `acb845d8`; J6.3 exact F751/Position destination correlation is closed at `6a063a27`; J6.4 exact graphical-host acknowledgement and owner cleanup is closed at `18d17d8b`. J7's one graphical `GameRuntime` root is closed at `ce41efb9`, including the user's 2026-07-27 exact post-cutover visual acceptance. J8 closed Slice J at `a9a822f2` with one shared graphical/no-window root and generation-reset transaction. Slice K Linux headless/multi-session work is closed. K0's tested no-presentation Windows/Linux boundary closed at `aada8a37`, K1's portable single-session host at `f8cb840f`, K2's deterministic scheduler and shared bot API at `7e8acb74` plus `38e83640`, and K3's shared-content/isolation plus connected observer gate at `3f340125`. K4 closed through `776482da`: 1/5/10/30-root isolation and two-hour simulated endurance, death/randomized cancellation, committed resource ceilings, ten minutes of exact native Linux two-account connected sampling, ACE-confirmed graceful logout, and zero-debt Runtime/content convergence all pass. Slice L Linux graphical/platform work is parked at its L1 implementation checkpoint by user direction on 2026-07-27. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate. **Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file. **Slice L checkpoint:** L0 closed at `66f114b2` with one typed graphical @@ -86,6 +86,36 @@ full Release suite 12,221 passed / 4 skipped / 0 failed. Plan and ledger: in-client acceptance script: [`2026-08-09-campaign-ch-test-script.md`](../research/2026-08-09-campaign-ch-test-script.md). +**Campaign LA — launcher/installer/updater + retail character-select +(ACTIVE 2026-08-14):** the alpha-program launcher: an Avalonia app +(Windows + Linux) doing triple duty — install (DAT locate → `acdream-bake` +with progress → SHA record), update (GitHub Releases manifest, verified +download, atomic version swap, launcher self-update), and launch +(ThwargLauncher-model server × account × character profiles with full +in-UI CRUD; plaintext credential file by explicit user decision). +File-contract orchestration of both hosts: session config in (K1 shape + +plugins + login commands), password via child stdin, versioned JSONL +status events out. Adds the headless character-list probe, plugin hosting ++ login commands on both hosts, and the retail character-management +screen (recon-corrected: `gmCharacterManagementUI` is a flat listbox with +Enter/Delete/Restore — NO 3D preview on retail's select screen; Create is +a future campaign). Spec: +[`2026-08-14-launcher-campaign-design.md`](../superpowers/specs/2026-08-14-launcher-campaign-design.md); +plan + ledger: +[`2026-08-14-launcher-campaign.md`](2026-08-14-launcher-campaign.md). +LA0 through LA11's automated scope are review-closed: the portable path boundary, +failure-isolated launch/status contract, BCL-only launcher core, shared +composer-to-both-host-loader anti-drift gate, and character wire messages are +landed. The self-contained Avalonia launcher, transactional two-host plugin +lifetime, shared login-command route, Runtime-owned retail selection state, +authored DAT character screen, crash-safe verified installer, and atomic +cross-platform updater/self-updater are integrated. Windows group-isolated +Headless stop, isolated A/B update fixtures, strict status/redaction evidence, +and one exact Windows/Ubuntu operator script are also landed. The integrated +clean preflight passes 32/32 commands and 14,012 tests / 5 skips. Campaign code +is complete but not shipped: the connected/visual/real-DAT user gate is the +only remaining boundary. + **Remaining physics-divergence closeout (ACTIVE, checkpoint 2026-08-03):** the user then authorized retirement of the remaining proven collision/placement gaps before vendor work resumes. Nested retry, edge/StepDown/Path-6 ordering, exact cell diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md new file mode 100644 index 00000000..9d9949c4 --- /dev/null +++ b/docs/plans/2026-08-14-launcher-campaign.md @@ -0,0 +1,860 @@ +# Campaign LA — launcher / installer / updater + retail character-select + +**Status:** ACTIVE (started 2026-08-14) +**Spec (approved):** `docs/superpowers/specs/2026-08-14-launcher-campaign-design.md` +**Memory crib:** `claude-memory/project_launcher_direction.md` +**Branch:** `claude/acdream-launcher-credentials-4d2f7c` (merge to main at coherent checkpoints) + +Campaign LA ships the alpha launcher (Avalonia, Windows + Linux): triple-duty +launcher + installer + updater, ThwargLauncher-model profiles with full in-UI +CRUD, plaintext credential file (user-decided), file-contract orchestration of +`AcDream.App` and `AcDream.Headless`, plugins + login commands on both hosts, +the headless character probe, and the retail character-select screen (no +Create). All architectural decisions live in the spec — this plan sequences +the work. + +## Process (binding) + +- **Fable plans/sequences/integrates. Sonnet implements bounded slices. Opus + reviews at every slice boundary, dual-lens:** (a) architectural — ownership, + layering, dependency-guard integrity, seams; (b) retail fidelity vs + `docs/research/named-retail/` wherever the slice touches retail behavior. + Findings → fixes → narrow re-review. +- Max 3–4 agents in parallel including children; subagents never spawn + subagents; implementer prompts carry spec+plan paths, files-to-read, + acceptance criteria, commit style. +- `dotnet build` + `dotnet test` green before a slice is DONE; ≥1 commit per + slice tagged `Campaign LA`; retail deviations add their + `docs/architecture/retail-divergence-register.md` row in the same commit; + no workarounds without explicit user approval. +- Connected/visual gates are the ONLY stop-and-wait points; each gets an + exact script under `docs/research/` and non-blocked slices keep moving. + +## Slice map + +| Slice | Deliverable | Depends on | +|---|---|---| +| LA0 | `AcDream.Platform` extraction (`ApplicationPathSet`) + guard amendments | — | +| LA1 | Launch contract: App `--session-config` + stdin credential; status.jsonl writer both hosts; roster plumbing | LA0 | +| LA2 | Headless probe mode + `idle` policy | LA1 | +| LA3 | `AcDream.Launcher.Core`: profile store CRUD, config composition, spawn/supervise, status reader | LA0 (LA1 contract shapes) | +| LA4 | `AcDream.Launcher` Avalonia UI: CRUD views, per-char settings, sessions, probe action | LA3 | +| LA5 | Plugin hosting: headless `IPluginHost` + capability flag; session-driven plugin set both hosts | LA1 | +| LA6 | Login commands: parser-core extraction + execution on both hosts | LA1, LA5 | +| LA7 | Character-select: Runtime selection state + wire (delete/restore/error) + no-selector flow | LA1 | +| LA8 | Character-select authored retail screen (flat listbox — NO 3D preview, recon-corrected) | LA7 | +| LA9 | Installer: first-run wizard (DAT locate/validate, bake w/ progress, SHA record) | LA3, LA4 | +| LA10 | Updater: GitHub Releases manifest, download/verify/install/swap, self-update | LA3, LA4 | +| LA11 | Closeout: connected-gate script, roadmap/CLAUDE.md/memory, program ledger | all | + +Parallelism guide: LA3/LA4 (launcher side) proceed alongside LA5–LA8 (client +side) — different assemblies, no shared files. LA9/LA10 close the launcher +side; LA11 closes the campaign. + +## Linux posture (binding — user decision 2026-08-14) + +Everything the launcher does must WORK ON LINUX in this campaign, except +GUI client launches: the Linux graphical client is Slice L, parked at L1, +resuming later ("ok we will do it later"). Concretely: + +- **Linux-shipping in LA:** the Avalonia launcher UI, profile CRUD + + 0600-permission file, installer (manual DAT picker — the auto-detect + paths are Windows-only; `acdream-bake` is GL-free and runs on Linux), + updater (staged swap; Linux can replace a running binary but keep the + same staged-atomic flow), headless launches with plugins + login + commands, and the character probe. +- **Launcher UX on Linux:** the `gui` / `guiSelect` launch modes render + disabled with an explicit "requires the Linux graphical client (Slice + L)" note — never a silent failure. +- **Per-slice enforcement:** every slice touching Launcher.Core, Headless, + Runtime, Bake, or Platform runs its test projects on Linux (native + Ubuntu or WSL, matching the K-slice practice) before the slice is DONE; + LA4/LA9/LA10 additionally prove a real `linux-x64` self-contained + publish. LA11's connected-gate script gets a Linux section: launcher on + Ubuntu doing CRUD, probe, headless launch with plugin + login commands, + first-run install with a manual DAT path, and an update swap. +- When Slice L later ships, the launcher's Linux GUI modes light up with + NO launcher changes (the session-config contract is host-agnostic) — + that expectation is part of LA's design acceptance. + +## LA0 — `AcDream.Platform` extraction + +New BCL-only project `src/AcDream.Platform/` holding `ApplicationPathSet` + +`IApplicationPathEnvironment` (today +`src/AcDream.Runtime/Platform/ApplicationPathSet.cs` — self-contained, no +intra-Runtime dependencies; clean cut). Runtime/App/Headless reference it. + +Recon facts (2026-08-14): blast radius is the definition, six source files +(`GraphicalHostPlatformServices.cs`, `GraphicalLegacyConfigurationMigrator.cs`, +`App/Program.cs`, `GameWindow.cs:533`, `HeadlessPathSet.cs`, +`HeadlessPlatformEnvironment.cs`; two more files are doc-comment-only), two +test files (`ApplicationPathSetTests.cs` moves to a new +`tests/AcDream.Platform.Tests/`; +`GraphicalLegacyConfigurationMigratorTests.cs` fixtures), and the dependency +guards — CORRECTED post-review (the original recon here asserted the wrong +guard, the C4-closeout failure mode): the K0 Headless guard +(`HeadlessAssemblyReferencesOnlyTheRuntimeProject`) asserts HEADLESS's own +csproj reference list, which this move does not touch — it stays UNCHANGED; +the guard that actually needs amending is Runtime's own +`RuntimeDependencyBoundaryTests.RuntimeProjectDeclaresOnlyApprovedProjectDependencies` +(Runtime gains the `AcDream.Platform` reference), amended with a cited +comment in the same commit. Namespace stays `AcDream.Runtime.Platform`? +NO — rename to `AcDream.Platform` and fix the eight usings (clean naming beats +avoiding a mechanical edit). Register new projects in `AcDream.slnx`. + +**Acceptance:** build + full test suite green; guard test asserts the new +exact reference set; launcher-side consumability proven by the LA3 project +referencing only `AcDream.Platform`. + +## LA1 — launch contract (client side) + +### Pinned launch-contract schema (v1, BINDING — committed per LA3 review) + +This text is the single source of truth for the launcher↔host file +contract. Both host readers (LA1), the composer (LA3), and the probe +loader (LA2) implement EXACTLY this; any change is an amendment to THIS +section first, implementations second. The LA1+LA3 merge adds a +cross-assembly test feeding a composer-produced document to both host +loaders — that test is the seam's permanent enforcement. + +Session-config document (System.Text.Json, camelCase, +`UnmappedMemberHandling.Disallow`, camelCase string enums): + +```json +{ + "version": 1, + "process": { + "content": { "datDirectory": "...", "preparedAssetPath": "..." } + }, + "sessions": [{ + "id": "sess-1", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "testaccount", + "mode": "probe", + "character": { "id": 1342177290 }, + "policy": { "id": "idle" }, + "credential": { "provider": "standardInput", "reference": "session" }, + "plugins": ["ExamplePlugin"], + "loginCommands": ["/vt start"], + "loginCommandDelayMs": 500, + "statusFile": ".../launcher/sessions/sess-1/status.jsonl" + }] +} +``` + +Field rules: +- `process.paths` is OMITTED unless a caller genuinely supplies overrides + (never an empty object — the App reader has no `paths` member and + strict parsing rejects unknown keys; LA3 review finding 1). +- `mode`: ABSENT for normal play sessions; `"probe"` for the LA2 probe + (connect → characterList → graceful disconnect, no EnterWorld). The + headless loader accepts the field starting at LA2. +- `character`: exactly ONE of index|id|name; OMITTED entirely (not null) + for guiSelect and for probe sessions. +- `policy`: `{ "id": "idle" }` for headless play sessions ONLY; omitted + for gui/guiSelect/probe. +- `credential`: always `{ "provider": "standardInput", "reference": + "session" }` for launcher-composed configs. +- `plugins`: absent/null means load all discovered plugins (preserving the + developer flow); explicit `[]` means load none. Launcher-composed + normal-empty and probe sessions emit `[]` so they cannot load arbitrary + machine-local plugins. +- `loginCommands`/`loginCommandDelayMs`/`statusFile`: optional, + omitted-when-unset (never null, never `[]` for empty). Absent + `loginCommandDelayMs` means 500. + +Status stream (`statusFile`, one JSON object per line, writer flushes per +line, writer opens `FileShare.Read`, tailer opens +`Read/FileShare.ReadWrite|Delete`): events `started`, `connected`, +`characterList{accountName,slotCount,characters[{id,name,secondsGreyedOut}]}`, +`enteredWorld{characterId,characterName}`, `pluginLoaded{plugin}`, +`pluginFailed{plugin,error}`, +`loginCommandFailed{commandIndex,command,error}`, +`characterCreated{guid,name}`, `creationFailed{code,reason,name}`, +`disconnected{reason}`, +`exited{code,reason}` — every line carries `"v":1`, `"e"`, `"t"` +(ISO-8601 UTC), `"sessionId"`. `secondsGreyedOut` is a uint on BOTH +sides. Unknown `e` values must parse to a typed Unknown event, never +throw; a known `e` with a wrong payload shape should be distinguishable +from an unknown `e` (LA3 review finding 12). + +**Campaign CC CC2 amendment (this section is the contract; the writer and +tailer below implement it, in that order):** `characterCreated{guid,name}` +fires on the Ok reply to a `CharacterCreate` (opcode `0xF656`) request — +`guid`/`name` come straight off the shared `0xF643` +`CharGenVerificationResponse` Ok identity payload +(`AcDream.Core.Net.Messages.CharGenVerificationResponse`), deliberately +named `guid`/`name` rather than `characterId`/`characterName` to mirror +that payload's own field names and to read distinctly from +`enteredWorld` — a freshly created character is logged straight in by +retail without a fresh `characterList` (see that type's doc comment), so +`characterCreated` can precede an `enteredWorld` for the same character +rather than replacing it. `creationFailed{code,reason,name}` fires on any +non-Ok reply: `code` is the raw wire `CharGenVerificationResponse.Code` +value, `reason` is that code's enum member name (e.g. `"NameInUse"`) so a +reader gets a stable readable reason without hard-coding the numeric +mapping itself, and `name` is the ATTEMPTED character name so a launcher +can render "the name Bob is taken". (CC2 review F4: the enum member +originally rode the `name` key, colliding in meaning with +`characterCreated.name`; renamed before any consumer shipped.) + +`loginCommandFailed.commandIndex` is the zero-based index in the configured +`loginCommands` array. `command` is the exact configured line and `error` is +the isolated parser/router/handler failure. The event is observational: the +host continues with the next configured line and never converts the command +failure into a login, plugin, session, or process failure. + +**Known LA1 status limitation:** the stream has no independent mid-play +wire-drop detector. If a transport becomes silent without raising through the +host's tick/teardown path, no immediate `disconnected` line can be promised; +the launcher must not treat the absence of that line as proof that the socket +is healthy. Explicit reconnect is ordered and observable — it emits +`disconnected{reason:"reconnect"}` before the replacement connection's second +`connected` — and normal stop/process teardown closes any still-open +connection before `exited`. A future transport-health signal may improve the +timing without changing this pinned event vocabulary. + +Three pieces, one slice, because they share the session-config/status seam: + +1. **App `--session-config `:** parsed once in `Program.cs` into + `RuntimeOptions` (code-structure rule 4); carries endpoint, account, + optional character selector, `Plugins`, `LoginCommands`, `Content` + (DatDirectory/PreparedAssetPath), status-file path, credential reference. + Recon: `Program.cs` has NO subcommand dispatch today — args handling is + one positional DAT-dir (`Program.cs:35`), so the flag is purely additive + (preserve the positional arg). The live-credential seam is a single call + site (`SessionPlayerComposition.cs:1128-1135` → + `LiveSessionConnectOptions`); the config path populates the same + `RuntimeOptions` fields from a different source. Env-var dev flow + untouched. App gains the `StandardInput` credential read (mirroring + `HeadlessCredentialResolver.ResolveStandardInput` — one line, immediately + wrapped in an erasable secret, redacted `ToString`; today + `RuntimeOptions.LivePass` is a bare string — the config path must not + widen that exposure). +2. **Status stream both hosts:** per-session `status.jsonl` (path given in + config; absent → permanent no-op sink). Versioned event + vocabulary (`"v":1`): `started`, `connected`, `characterList`, + `enteredWorld`, `pluginLoaded`/`pluginFailed`, + `loginCommandFailed`, `characterCreated`/`creationFailed` (Campaign CC + CC2), `disconnected`, `exited`. + Recon: today's `HeadlessDiagnosticWriter` is a single shared-stdout JSONL + sink with four kinds (lifecycle/failure/event/resources) and NO per-session + file — the status writer is a second, separate sink, not a rework of the + diagnostics writer. App has no structured writer today; it gets the same + shared implementation (lands in Runtime so both hosts borrow it). +3. **Roster plumbing:** `CharacterList.Parsed` is consumed inside + `LiveSessionController.StartCore` (`LiveSessionController.cs:612`) and + never escapes — add a typed roster report on the lifecycle-host seam + (`ILiveSessionLifecycleHost`) so hosts can emit the `characterList` status + event and (later) the char-select screen can populate. No behavior change + to selection itself in this slice. + +**Acceptance:** round-trip tests (config → `RuntimeOptions`; stdin credential; +status events in order with exact shapes; roster surfaced); App/Headless/ +Runtime suites green; redaction test proves the password never appears in +status/diagnostics output. + +## LA2 — headless probe mode + `idle` policy + +Recon facts: the probe's shape already exists as the `NoCharacters` early-exit +(`LiveSessionController.cs:613-622` → `StopCore()` → 4-stage +`SessionScope.DrainTeardown`, graceful, `_inWorld == false` so no pre-logoff +flush) — but it fires only on selection FAILURE and maps to exit code 5 +(`HeadlessProcessHost.RunOnUpdateThread:203-212` treats any non-`Connected` +start as `ConnectionError`). + +1. **Probe:** a `Probe` flag on the connect options short-circuits `StartCore` + right after `GetCharacters` (before `TrySelectCharacter`): report roster, + `StopCore()`, return a NEW `LiveSessionStartStatus.ProbeComplete`. + `HeadlessProcessHost` maps it to exit code 0 with a final `characterList` + + `exited(reason: "probe")` status pair. Config: `mode: "probe"` on the + session descriptor relaxes the `JsonRequired` character selector + policy + for probe sessions ONLY (loader keeps strict validation otherwise — + recon: violations currently surface as raw `JsonException` → exit 3; probe + relaxation must be shape-level in the loader, not attribute removal). +2. **`idle` policy:** new consumer `HeadlessBotPolicy` id — enter world, run + plugins/login-commands (arrive in LA5/LA6), stay until stopped, clean + SIGINT teardown (K4's graceful-logout path already proves the mechanism). + +**Acceptance:** probe test (fixture session → roster event → graceful teardown +receipt → exit 0, no `EnterWorld` on the wire); loader tests for probe-shape +relaxation + strict normal validation; idle-policy lifecycle test; suites +green. Connected verification (user gate, LA11 batch): live probe against ACE +twice in a row with no lingering session (spec §11.9). + +## LA3 — `AcDream.Launcher.Core` + +New BCL-only project + `tests/AcDream.Launcher.Core.Tests/`. References +`AcDream.Platform` ONLY. + +- Profile store: `launcher-profiles.json` (spec §5 schema) — load/save/ + validate, full CRUD operations, roster merge (fold `characterList` events + in, preserving per-character user settings), 0600 on Linux. +- Session-config composition: profile + install records → the LA1 config + shape (typed writer; probe shape included). Passwords excluded — stdin only. +- Process orchestration: spawn App/Headless per launch mode, feed password to + child stdin then close, supervise lifetime, tail `status.jsonl` + (share-tolerant reads), surface typed session state. +- SHA-256 utility (pak record + download verify — consumed by LA9/LA10). + +**Acceptance:** CRUD/round-trip/merge tests; composition tests (all three +modes + probe); supervision tests against a fake child process (echo script); +status-tail tests including partial-line handling; suites green. + +## LA4 — `AcDream.Launcher` (Avalonia) + +New Avalonia project (Windows + Linux). MVVM over Launcher.Core; no game +solution references beyond `AcDream.Platform` transitively. + +- Views: server list → accounts → characters tree; add/edit/remove dialogs + for servers (name/host/port) and accounts (account + password entry); + per-character settings editor (launch mode, plugin set, login commands); + per-account "refresh characters" (probe); running-sessions status column. +- Launch actions per mode (`gui` / `guiSelect` / `headless`); probe disabled + while the launcher runs a session for that account. +- First-run wizard shell + update prompt shell (bodies land in LA9/LA10). + +**Acceptance:** ViewModel tests in Launcher.Core.Tests patterns (VMs live in +the Avalonia project but stay logic-thin; anything testable pushes down); +build green on Windows; `linux-x64` publish compiles. Visual polish is gated +at LA11 (user). + +## LA5 — plugin hosting on both hosts + +Recon facts (2026-08-14): `PluginLoader`/`PluginDiscovery`/`PluginManifest` +already live in `AcDream.Core` (Headless-reachable). App's single load loop +(`App/Program.cs:110-121`) loads ALL discovered plugins from two roots +(`AppContext.BaseDirectory/plugins` + `ApplicationPathSet.PluginsDirectory`, +dup-id skip) — no allow-list exists on either host. `AppPluginHost` is a +26-line pass-through; three of four `IPluginHost` surfaces (`State` → +`WorldGameState`, `Events` → `WorldEvents`, `Selection` → `SelectionState`) +are backed by Core-owned types already; only `Ui` (`BufferedUiRegistry`) is +genuinely App-only. Headless has zero plugin hosting today (confirmed). + +1. Session-config `Plugins` allow-list filters the discovery result on BOTH + hosts (absent/null list = load all, preserving today's dev behavior; + explicit `[]` = load none). Launcher-composed normal-empty and probe + sessions emit `[]`. +2. `HeadlessPluginHost : IPluginHost` in Headless over the same Core-owned + `State`/`Events`/`Selection`; `Ui` is an explicit no-op behind a new + capability flag on `IPluginHost` (e.g. `HasUi`) so plugins can detect + headless. Contract documented in `Plugin.Abstractions`. +3. `pluginLoaded`/`pluginFailed` status events from both hosts' load loops. + +**Acceptance:** fixture plugin in Headless suite (load, capability flag, +markup no-op, teardown via collectible ALC); allow-list filter tests both +hosts; status events asserted; suites green. + +## LA6 — login commands on both hosts + +Recon facts (2026-08-14): the command core is dependency-CLEAN — +`ChatInputParser` (zero usings), `ChatCommandRouter` (BCL + +`AcDream.Core.Chat`), `RetailClientCommandCatalog` (FrozenDictionary), +`ChatVM` (Core.Chat/Combat + `System.Numerics` only), `ICommandBus` + the +four command records (BCL-only). The block is assembly identity, not +coupling. `ChatCommandRouter.Submit`'s two entanglements: a hard `ChatVM` +parameter (uses only `ShowInterfaceText`/`ShowSystemMessage`/ +`LastIncomingTellSender`/`LastOutgoingTellTarget`) and the `ICommandBus`, +whose production implementation (`LiveSessionCommandRouter`, +`App/Net/LiveSessionCommandRouter.cs`) is App-only and wraps wire-send +delegates from the live session. GUI already has a login-command analog: +`RetailUiAutomationScriptRunner` feeds `ChatCommandRouter.Submit` at +`RetailUiRuntime.cs:523-527`. + +1. **Extraction:** move parser/router/catalog + `ICommandBus` + the four + command records (+ sibling tables they require) into Runtime + (`AcDream.Runtime/Chat/...`); the router's `ChatVM` parameter becomes a + narrow feedback interface defined beside it (exactly the four members + used); `ChatVM` (stays in UI.Abstractions) implements it. GUI path stays + bit-identical — same call sites (`ChatWindowController.cs:326`, + `FloatingChatWindowController.cs:157`), same routing, CH-accepted + behavior regression-checked by the existing chat suites. +2. **Headless dispatch:** a Runtime/Headless `ICommandBus` binding the same + session send delegates (`SendTalk`/`SendTell`/`SendChannel`/ + `SendTurbineChat`) + Runtime state that App's router binds — paralleling + `LiveSessionCommandRouter`'s registrations, feedback lands in + `RuntimeCommunicationState.AddText`. +3. **Execution:** both hosts run `LoginCommands` sequentially as-if-typed + (default 500 ms inter-command delay, config-overridable) once + entered-world; per-command failures → status stream, never abort. +4. K0 guard: if the code folds into Runtime, the single-reference assertion + stands untouched; the forbidden-prefix closure tests keep passing. Any + guard text change is deliberate and documented. + +**Acceptance:** extraction lands with zero GUI chat test regressions +(UI.Abstractions + App chat suites bit-green); headless executes a +login-command script against a fixture session with ordered wire sends; +delay + failure-tolerance tests; suites green. + +## LA7 — character-select: state + wire + +Recon facts (2026-08-14): retail's screen is `gmCharacterManagementUI` +(`acclient.h:56545`) — flat listbox + Create/Enter/Delete/Restore buttons + +dialog contexts. **No 3D preview exists on retail's select screen** (the +`gmCG3DView`/`CreatureMode` viewport is chargen-only; the old +"rotating pedestal" line in `retail-ui/05-panels.md` §13 is uncited and +wrong). Our `CharacterList` parse already matches ACE's serializer exactly +(two-array shape, status/deleted always zero from ACE) and the two-phase +enter-world (0xF7C8 → 0xF7DF → 0xF657) is implemented. Missing wire: +delete/restore/error. + +1. **Wire messages** (`AcDream.Core.Net/Messages/`, retail citations in + file docs per house style): `CharacterDelete` 0xF655 — outbound + account String16L + **slot index** (`Proto_UI::SendDeleteCharacter + @0x00546b30`; NOT guid), inbound opcode-only ack followed by a fresh + CharacterList; `CharacterRestore` 0xF7D9 guid-only (ACE + holtburger + consensus; the decomp's apparent extra strings are a decompiler + artifact — spec §11.4), response 0xF643 (flag + guid + name + + secondsDisabled); `CharacterError` 0xF659 parser (new — today NO + character-stage server error can be surfaced). +2. **Runtime selection state** (J-owner pattern): roster with per-entry + greyed/pending-delete state (`SecondsGreyedOut != 0` ⇒ pending; ACE + sends a constant 1 during the grace window — treat as boolean, never a + countdown), highlight, pending-delete dialog state, typed commands + (highlight / enter / delete-request / delete-confirm / restore). + Retail behavior oracles: `RebuildCharacterList@0x004ec3a0`, + `SelectCharacter@0x004ec160`, `UpdateButtons@0x004ec240` + (Delete↔Restore swap on greyed state), `EnterGame@0x004ed440`. +3. **No-selector flow:** a graphical session config without a character + selector stops at selection state instead of auto-enter; the + first-available fallback (`CharacterList.TrySelectFirstAvailable`, + used at `LiveSessionController.cs:848-851`) remains ONLY for + selector-carrying/headless sessions. Selection feeds the existing + `EnterWorld` path unchanged. + +LA7b hazards carried from the LA7a review (2026-08-14): ACE's restore +handler has a SILENT no-reply path (unknown guid → `return;`, no 0xF643, +no 0xF659) — selection state must never block awaiting a restore reply; +outbound routing is delete via retail's SendToLogon, restore via +SendToControl, ACE replies on UIQueue; `charError.NumErrors` (0x19) is an +enum-range sentinel and must never render as a user-facing message. +Register row AD-97 (guid-only restore request, an adaptation) rides the +LA7a branch. + +**Acceptance:** message round-trip tests against ACE's serializer shapes; +selection-state tests (greyed transitions, delete→list-refresh, restore, +error surfacing); no-selector stop + enter flow tests; suites green. + +## LA8 — character-select: authored retail screen + +Scope: project LA7's state through the REAL retail screen. No 3D preview +(recon-corrected; a preview would be an unapproved divergence). + +1. **Layout resolution:** retail resolves the root via + `UIMainFramework::CreateAndAddRootElement(0x10000005, 0x1000039a)` + + `DBObj::GetDIDByEnum(..., 5)` — reuse OP8's ported GetDIDByEnum + machinery (category 4 precedent) for enum-table 5; slice starts by + dumping that table from installed DATs to pin the concrete DataID. + Child ids: listbox `0x1000039d`, create `0x100003a0` (present, + disabled — Create is a future campaign), enter `0x100003a2`, delete + `0x1000039f`, restore `0x1000039e`. +2. **Dialogs:** delete-confirm, please-wait, entering-world, error — the + retail dialog machinery from the OP8 WaitDialog work (`2a81e813` + mapped WaitDialog class type 0x19) is the base. +3. **Open item resolved here:** whether retail draws a render-loop + background scene behind the UI (pseudo-C proves only that the UI class + owns no viewport) — settle via user recollection + the visual gate + before polishing. + +**Acceptance:** authored screen builds from DAT assets; button-state +matrix matches `UpdateButtons` oracle (incl. Delete↔Restore swap); +enter/delete/restore/error flows drive LA7 state end-to-end; suites +green. User visual gate at LA11 (screen look, dialog flows, delete + +restore against local ACE). + +## LA9 — installer (first-run) + +- DAT locate: auto-detect `%USERPROFILE%\Documents\Asheron's Call` and + `C:\Turbine\Asheron's Call` + manual picker; validate the four DATs. +- Bake: spawn `acdream-bake --dat-dir --out /pak/acdream.pak + --threads N`. Recon: default `--out` is INSIDE the DAT dir — the launcher + always passes `--out` explicitly. Progress: add `--progress-json` to + `AcDream.Bake` (JSONL progress lines alongside the existing 5-second human + text, which stays default) — scraping human text is fragile and we own the + tool. Recon: the bake has NO whole-file SHA — after a successful bake the + LAUNCHER computes and records SHA-256 + size + `BakeToolVersion` in its + install record, and re-verifies on subsequent startups (fast corruption + check trades a few seconds of hashing for never launching against a + half-written pak). +- Install record feeds LA3's session-config composition + (DatDirectory/PreparedAssetPath). + +**Acceptance:** wizard flow tests over Launcher.Core (fake bake child emitting +`--progress-json` lines); bake-tool progress flag tests in +`tests/AcDream.Bake.Tests`; SHA record/verify tests; suites green. Connected +gate (user): clean-profile first-run against real DATs. + +## LA10 — updater + +- Manifest: GitHub Releases; `manifest.json` release asset — version, per-RID + client zip URL + SHA-256 + size, minimum-launcher version. Launcher pins + owner/repo in its config. +- Client update: poll on launch (+ manual check), download to staging, SHA + verify, unpack to `DataDirectory/app//`, atomic `current.json` + pointer swap, refuse while any session runs, keep previous version for + one-step rollback. +- Launcher self-update: staged download + target-local atomic replacement on + next start. +- Session-config composition targets `app/current`'s binaries. + +**Acceptance:** manifest/download/verify/swap tests against a local HTTP +fixture; rollback test; refusal-while-running test; self-update staging test; +suites green. Connected gate (user): staged-manifest update swap end-to-end. + +### Pinned updater contracts (v1, BINDING) + +This section is the single source of truth for every LA10 feed and on-disk +shape. Readers use strict, case-sensitive `System.Text.Json` parsing, reject +unknown or duplicate properties, and reject unsupported schema versions +before doing network, extraction, or activation work. + +The production feed is pinned to GitHub owner/repository +`eriknihlen/acdream`; the launcher reads +`https://github.com/eriknihlen/acdream/releases/latest/download/manifest.json`. +Tests use a separate internal fixture constructor that may admit loopback HTTP; +that allowance never propagates to the production feed. Production manifest +and artifact URIs use HTTPS. Automatic redirects are disabled and every +redirect hop is validated before it is requested; redirect loops, a chain over +five hops, and any HTTPS-to-HTTP downgrade are rejected. `manifest.json` is: + +```json +{ + "schemaVersion": 1, + "version": "1.2.3", + "minimumLauncherVersion": "1.1.0", + "clients": { + "win-x64": { + "url": "https://github.com/eriknihlen/acdream/releases/download/v1.2.3/acdream-client-win-x64.zip", + "sha256": "<64 hex characters>", + "size": 123 + } + }, + "launchers": { + "win-x64": { + "url": "https://github.com/eriknihlen/acdream/releases/download/v1.2.3/acdream-launcher-win-x64.zip", + "sha256": "<64 hex characters>", + "size": 123 + } + } +} +``` + +`version` and `minimumLauncherVersion` are strict SemVer 2.0 strings. Build +metadata is ignored for precedence; numeric identifiers are compared without +fixed-width integer overflow. RID keys are exact lowercase portable RIDs. +Both dictionaries are required and the running RID must have a client and a +launcher row. Artifact sizes are positive and capped by the launcher's +download limit; SHA-256 is exactly 64 hex characters. ZIP URLs are absolute. +Client ZIPs have the two host executables at their root +(`AcDream.App[.exe]`, `acdream-headless[.exe]`); launcher ZIPs have +`acdream-launcher[.exe]` at their root. No implicit wrapper directory exists. + +Every extracted client version has +`DataDirectory/app//install.json`: + +```json +{ + "schemaVersion": 1, + "version": "1.2.3", + "rid": "win-x64", + "archiveSha256": "<64 hex characters>", + "archiveSize": 123, + "files": [ + { "path": "AcDream.App.exe", "sha256": "<64 hex characters>", "size": 123, "unixMode": 0 } + ] +} +``` + +Paths use `/`, are relative, normalized, unique under ordinal-ignore-case, +and sorted ordinally. `unixMode` contains only the portable permission bits +captured from the ZIP entry. Startup verifies every recorded regular file by +size/SHA, rejects unrecorded files/reparse points, and requires the two host +executables before admitting a version. Extraction uses a random sibling +directory under `DataDirectory/app/`; promotion to `/` is one +same-volume directory rename. + +`DataDirectory/app/current.json` is the only activation authority: + +```json +{ "schemaVersion": 1, "currentVersion": "1.2.3", "previousVersion": "1.1.0" } +``` + +`previousVersion` is omitted for the first activation. Pointer writes are +write-through temporary-file + same-directory atomic rename. The last valid +pointer is also atomically preserved as `current.previous.json`; startup may +restore that exact backup only when `current.json` is missing/malformed and +the referenced version verifies. Orphan LA10 staging directories, download +archives, corrupt-version quarantine directories, and pointer temporaries are +transaction-owned by exact lowercase GUID names and are removed only under the +exclusive update lease; near-matching user names are preserved. A corrupt +installed version is never silently selected; the explicit one-step rollback +swaps the two verified pointer versions. + +`DataDirectory/app/.update-session.lock` is the cross-process barrier. Each +supervised launcher activity holds a shared OS handle from before executable +resolution until terminal process observation; launcher disposal requests +child termination and does not release that handle until the child is actually +observed terminal. An update/rollback holds the +exclusive handle for its entire recovery/download/extract/promote/pointer +transaction. Failure to acquire the exclusive handle is an immediate refusal, +not a wait behind a running session. The open handle, not lock-file contents, +owns the lease and therefore releases after process death. + +Launcher self-update staging lives at +`DataDirectory/launcher-update/transactions//` and the sole +durable authority is `DataDirectory/launcher-update/pending.json` (schema 3): + +```json +{ + "schemaVersion": 3, + "transactionId": "0123456789abcdef0123456789abcdef", + "state": "staged", + "version": "1.2.3", + "rid": "win-x64", + "targetDirectory": "", + "archiveSha256": "<64 hex characters>", + "archiveSize": 123, + "files": [ + { "path": "acdream-launcher.exe", "sha256": "<64 hex characters>", "size": 123, "unixMode": 0 } + ], + "apply": null +} +``` + +Before mutation the verified staged launcher becomes the next-start helper and +waits for the initiating launcher PID without invoking a shell. It first copies +the complete verified payload into the target-local +`.acdream-self-update-/incoming/` tree. The plan then advances +to `applying`; `apply` is an ordinally sorted union of new payload paths, the +owned metadata path, and obsolete paths from the previous ownership record: + +```json +[ + { + "path": "acdream-launcher.exe", + "operation": "install", + "hadOriginal": true, + "priorSha256": "<64 hex characters>", + "priorSize": 123, + "priorUnixMode": 0, + "replacementSha256": "<64 hex characters>", + "replacementSize": 456, + "replacementUnixMode": 0 + }, + { + "path": "new-support.dat", + "operation": "install", + "hadOriginal": false, + "priorSha256": null, + "priorSize": null, + "priorUnixMode": null, + "replacementSha256": "<64 hex characters>", + "replacementSize": 456, + "replacementUnixMode": 0 + } +] +``` + +Every `hadOriginal` entry persists the exact pre-mutation SHA-256, length, and +Linux mode bits; a no-original entry has all three prior fields null. Every +install entry likewise persists the verified replacement metadata, while a +remove entry has all three replacement fields null. The journal is invalid +unless those fields agree with `hadOriginal` and `operation`. + +Existing targets are replaced with one same-filesystem atomic replace whose +backup is also target-local. Previously absent noncanonical files use one +same-filesystem rename; obsolete owned files use one rename into backup. The +canonical launcher path therefore contains either the complete old file or the +complete new file at every durable crash boundary. Rollback first performs a +zero-mutation preflight of the complete target-local transaction and every +journal entry. It rejects reparse points, unsafe parents, unrecorded paths, +ambiguous file layouts, and any SHA-256/length/mode mismatch in a prior, +incoming, or discard file. Only a fully preflighted rollback may atomically +restore backups; newly created files move to target-local discard rather than +being deleted. The complete prior target set is then reverified before the +plan enters durable `rolledBack` state while retaining the journal. Retry is +allowed only after that prior set is reverified again and the plan returns to +`staged`. Thus rollback is atomic per file and idempotent after a process/power +loss. Any ambiguity preserves the applying plan and transaction evidence and +forbids launching the canonical path for manual recovery. Linux mode bits come +from the verified incoming file. A helper that cannot immediately +acquire the exclusive update lease defers the staged plan and exits without +restarting the old launcher, preventing restart loops. + +Successful application writes strict target ownership metadata at +`/launcher.install.json`: + +```json +{ + "schemaVersion": 1, + "version": "1.2.3", + "rid": "win-x64", + "files": [ + { "path": "acdream-launcher.exe", "sha256": "<64 hex characters>", "size": 123, "unixMode": 0 } + ] +} +``` + +The archive may not supply that reserved metadata path. A prior valid record is +the only authority for obsolete-file removal; the first managed update does +not infer ownership of unrelated legacy files. On success the plan becomes +`awaitingConfirmation`; the new launcher confirms at its first managed +instruction, after which the helper releases its lease and the confirmed +launcher reclaims plan, data-transaction, and target-local residue. An +`applying` plan is rolled back before retry, and failure to start/confirm the +new launcher restores every original (and removes every no-original target). +The helper restarts the restored canonical launcher only after a fresh complete +verification of the retained `rolledBack` journal; rollback corruption or an +unsafe backup/discard tree exits without starting either launcher. +Reading `pending.json` never performs cleanup. Ordinary startup attempts the +exclusive lease without waiting and skips update cleanup entirely when another +session/staging transaction owns it. All plan paths are re-derived/contained +under pinned roots; the target directory must equal the actual launcher base +directory. + +Every portable archive and persisted relative path rejects Windows device +segments on every host: `CON`, `PRN`, `AUX`, `NUL`, `CLOCK$`, `CONIN$`, +`CONOUT$`, `COM1`-`COM9`, `LPT1`-`LPT9`, and the Windows-equivalent superscript +forms `COM¹`/`COM²`/`COM³` and `LPT¹`/`LPT²`/`LPT³`, including extensions. + +## LA11 — closeout + +- One exact operator script + `docs/research/2026-08-14-campaign-la-test-script.md`, fronted by the + connection-free `tools/run-campaign-la-preflight.ps1` and followed by + serial user rows, + covering: all three launch modes vs local ACE, probe round-trip ×2 (no + lingering session), char-select visual matrix + delete flow, login-commands + + plugin behavior on both hosts, add-server/add-account purely in UI, + clean-profile first-run wizard, staged update swap. +- Roadmap shipped-table entry, CLAUDE.md Current-state flip, memory distill, + ledger below completed, program closeout section. + +## Review protocol + +Per slice: implementer commit(s) → Opus dual-lens review (architectural + +retail-where-applicable) → fix round → narrow re-review of fixes → slice DONE +in ledger. Reviews name blast radius explicitly +(`claude-memory/feedback_blast_radius_single_lens.md`). Slices LA7/LA8 add the +retail-fidelity lens against named-retail symbols cited in the slice body; +LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny. + +## Gate round 1 — 2026-08-15 (first live launch by the user) + +The user's first hands-on launch found the launcher exiting on every click. +Root cause (`d54b8a78`): `MainWindow`'s constructor called +`AvaloniaXamlLoader.Load(this)` instead of the generated +`InitializeComponent()`, so every `x:Name` backing field was null and any +modal open/close threw out of the dispatcher into `Program`'s exit-74 +guard. It reached the gate because NO test constructed `MainWindow` — +filed and closed as **#399** (`2b439cc1`, merged): `Avalonia.Headless.XUnit` +view tests with falsification evidence (12/12 fail against the old code, +12/12 pass against the fix; launcher suite 66/66 Windows + native Ubuntu; +xunit→xunit.v3 in that test project). Same round (`e1e94697`): **#398** +closed — fatal exceptions now write a full-stack crash report under the +data root (isolated-roots-safe; the first cut leaked to the real data root +when parsing failed, caught live and fixed) — and `acdream-bake.exe` is now +co-deployed on plain Build, not just Publish, so a developer-built launcher +can actually run its first-run wizard (79.6 MB single file beside the +launcher, incremental, `--help` verified). One transient 65/66 on the first +post-merge test run did not reproduce across a clean rebuild + six repeats — +consistent with stale-artifact mixing, but if it EVER recurs, capture the +failing test name before anything else. Merged slice worktrees/branches +(la2/la3/la7a/la-uitest) removed. Opus batch review: PASS (HIGH +confidence) with 6 findings, all landed same-day: F1 the crash reporter's +by-construction claim was FALSE (the launcher holds passwords in three +fields; the true invariant — no throw site interpolates a credential +value — is now pinned by a forced-failure test), F2 the co-deploy's +Inputs covered only Bake's own sources, not its Content/Platform/Core/ +Plugin.Abstractions closure (the stale-artifact class again; fixing it +exposed and fixed two more incrementality traps: SkipUnchangedFiles +leaving outputs older than inputs, and %(Item.Metadata) in a plain +Include not batching — a literal '%(...)' input is permanently +out-of-date), F3 dual bake publish on RID publishes (guarded by +_IsPublishing; verified 0 build-target co-deploys during a real publish), +F4 misattributed comment, F5 template-scoped x:Name false-fail (sweep now +walks the XML with template-ancestor tolerance), F6 dead using, plus the +optional Path.IsPathFullyQualified hardening on the crash reporter's +--data-dir fallback. Launcher 67/67, Launcher.Core 317/317. The §A–I +connected script remains the open user gate. + +## Gate round 2 — 2026-08-15 (first live launcher→client flow) — char-select matrix USER-PASSED + +**USER-PASSED 2026-08-15 (end of round):** the character-select visual/ +interaction matrix — stretched-canvas look with bilinear filtering, +aligned widgets, left-justified roster, World box reading the live server +name ("sawato"), and the centered exit confirmation — all accepted on the +live launcher→client flow. Round-2 commits after the round-1 batch: +`6e1c0967` (session-config launches force the retail UI), `9ce72925` +(PFID_CUSTOM_RAW_JPEG decode + resolution guards), `73041d70` +(whole-canvas AD-98 scale + inverse input), `308f40a3` (linear-twin +bilinear stretch), `ef96c554` (exit confirmation + authored justify + +world name, AD-99), `2e6d69dd` (#400), `0a7dc7d6` (durable world-name +read + canvas-centered dialogs). Remaining before shipment: the formal +§A–I script rows (probe ×2, headless+plugins+login commands, delete/ +restore, A→B update swap, row I Linux), and the final-HEAD preflight +re-run. + +**Round REVIEW-CLOSED 2026-08-15:** the owed Opus dual-lens batch review +of the six round-2 commits returned PASS with 8 findings; the fix round +(`0baebce2` — headline: `RetailWaitDialogView` was the ONE dialog view the +EffectiveCanvasSize sweep missed, firing on ENTER; plus the two stale +deleted-mechanism doc assertions, the Confirmation `0xAC` property, +truncating input mapping, the IsCurrent world-name gate, the AD-98 +evidence note) closed all seven in the narrow re-review; F2 filed as +#401 (invert RetailUi to opt-out). The review also proved the +`DatWidgetFactory` justify widening has ZERO regressions across all 35 +layout fixtures (303 buttons swept; the 16 authored-Left all already +left-aligned via their face-child branch) and is a move TOWARD retail +(`CalcJustification @0x00467260` has no lifted-from-child condition). + +Two real defects, both root-caused and fixed: + +1. **`6e1c0967` — launcher-spawned clients had NO interface at all.** + `RetailUi` rode the dev env var `ACDREAM_RETAIL_UI`; `FromSessionConfig` + inherited the env parse; the launcher strips `ACDREAM_*` from children + (LA11 isolation). Product launches therefore got the dev default: world + rendering, zero UI — character screen included. Session-config launches + now force `RetailUi = true` (a session-config launch IS a product + launch); the env flag remains the dev-launch opt-in. Test-pinned with a + null env. +2. **`9ce72925` — character-select screen rendered magenta background/ + fills.** The screen's 800×600 root background (`0x06007576`) is + `PFID_CUSTOM_RAW_JPEG` — a complete JFIF stream retail hands to the + Intel JPEG Library (`RenderSurface::CreateFromSourceData @0x004440a0`), + with Width/Height legitimately 0 on disk. `SurfaceDecoder` had no JPEG + case AND a non-positive-dimension guard, so it fell silently to the + magenta placeholder; the listbox/ENTER fills are transparent, so one + broken background bled through as three symptoms. Fixed via + StbImageSharp (managed, Linux-safe; codec-library substitution per the + BCnEncoder precedent — no register row). BOTH silent traps now log once + per id (id-resolves-but-undecodable in `SurfaceDecoder`; + id-missing-from-DATs in `TextureCache`) — the existing magenta guard + only covered id-0. New installed-DAT sweep asserts every char-select + media id decodes non-magenta. Full suite 14,034 green. + +Session-orchestration facts this round: the machine gained PowerShell 7 +(winget, user-approved — the LA fixture tooling hard-requires it); an +orphan feed server from the earlier session held port 43119 with stale +fixture data (stopped); the launcher self-update bootstrap restart on a +dev binary is EXPECTED (staged launcher update → exit → respawn). +Observations still open for this round: the duplicated "versioned client +is unavailable" status line (cosmetic), and verifying Create Character is +disabled on the live screen. + +## Ledger + +| Slice | Status | Commits | Review | Notes | +|---|---|---|---|---| +| LA0 | **DONE 2026-08-14** | `cb6502c8`, `a49e92df` | Opus dual-lens PASS; all 6 findings CLOSED in narrow re-review | Byte-identity proven; Linux CI lanes restored; Platform BCL-only self-guard added; K0 guard untouched | +| LA1 | **DONE 2026-08-14** | `db9ad53c` (mixed — see `e1322a06`), `75a6724d` (recovery WIP), `d511e4c3`, ledger `890cf267` | Initial review FIX FIRST; F1–F8 CLOSED; narrow dual-lens re-review PASS | Release build green (0 errors / 18 warnings). Windows: Runtime 1634 / Headless 127 / App 5038+3skip. WSL: Runtime 1634 / Headless 127. Known mid-play silent-wire-drop limitation recorded above. The LA1+LA3 composer-to-both-hosts contract gate and portable CI lane landed at `8a03a25f`. | +| LA2 | **DONE + MERGED 2026-08-14** | `c6019424` (recovery WIP), `000ea979`, `1c5e66c0`, merge `e01b2cd1` | Dual-lens review FIX FIRST; all 3 findings CLOSED; final narrow re-review PASS | Probe success requires a reported roster and remains before selection/EnterWorld; terminal status derives from the actual start outcome; conditional fields distinguish omission from explicit null without weakening strict JSON. Branch gates: Runtime 1,632/1,632 and Headless 149/149 on both Windows and Ubuntu/WSL. Integrated gates: Release solution build green; Windows Runtime 1,636/1,636, Headless 151/151, App 5,039+3 skip, Launcher.Core 114/114; WSL Runtime 1,636/1,636, Headless 151/151, Launcher.Core 114/114. Repeated live ACE probe remains the LA11 user gate. | +| LA3 | **DONE + MERGED 2026-08-14** | `37d74e44`, `26feba81`, `347a1a5d`, merge `7749545d`, seam `8a03a25f` | Initial 12 findings CLOSED; four-gap narrow review FIX FIRST; final narrow re-review PASS | `AcDream.Launcher.Core` remains BCL + Platform only. Windows/WSL Core 114/114; full Release build green. Composer output is parsed by BOTH real host loaders from one linked fixture; Launcher.Core build/tests run in the portable Windows+Ubuntu lane. Windows graceful-stop gap remains tracked as #397. | +| LA4 | **DONE + MERGED 2026-08-14** | `d0a9c65d`, `10a712d6`, `ae2cbbee`, merge `60f62799` | Initial dual-lens review found 10 issues; fix re-review left one Linux execute-bit gap; final narrow re-review PASS | Avalonia 12.1.1 launcher remains thin over one BCL-only Core orchestrator. Windows/WSL Launcher.Core 162/162 and Launcher 17/17. Native `linux-x64` publish evaluates self-contained + single-file, runs without a discoverable runtime, and CI verifies executable launcher/App/Headless artifacts. LA9/LA10 bodies and LA11 visual/accessibility confirmation remain intentionally later. | +| LA5 | **DONE + MERGED 2026-08-14** | `95f4be94`, `fbe9c8a2`, `f820eb25`, merge `5535d0ad` | Initial review found 5 issues; first narrow re-review found 4 ownership/race gaps; final narrow re-review PASS | Both hosts share exact absent/null=`all`, `[]`=`none` allow-listing; transactional scoped UI/entity/selection rollback precedes unload; graphical/headless status and teardown ordering match; headless replay is exact-once under Runtime's borrowed membership lease. Branch complete suite 13,679+4 skip; portable WSL closure green. | +| LA6 | **DONE + MERGED 2026-08-14** | `41b15efd`, `259f0e5a`, merge `2bb8ccb6` | Dual-lens/CH regression review found one Headless wire-parity gap; narrow re-review PASS | Runtime owns the sole parser/router/catalog and shared four-route live binding. Both hosts run generation-scoped login commands after world entry with strict monotonic delay and nonterminal v1 failure status. Headless permit/chat/notell semantics match App. Branch complete suite 13,787+4 skip; WSL Runtime 1,662, Headless 165, Launcher.Core 167, UI/chat 922. | +| LA7 | **DONE + MERGED 2026-08-14** | LA7a `6a32f375`, `4338b1c1`, `0c8643a7`, merge `fa2de1c4`; LA7b `0e82cbf7`, `1b9e7e41`, `ff406562`, merge `7691cf75` | LA7a retail-lens PASS; LA7b review found 4 issues, first narrow pass left one restore/delete interleave, final narrow re-review PASS; AD-97 filed | Runtime owns the sole generation-scoped pre-world selection graph. Exact retail roster/grey/button/delete/restore behavior and queue routing are preserved; `NumErrors` is a sentinel, paused selection retains reliable transport sweeping, silent restore cannot block, and App has no mirror. Windows Runtime 1,653, Core.Net 958, App 5,042+3 skip; WSL Runtime/Core.Net green. | +| LA8 | **DONE + MERGED 2026-08-14** | `6cfab727`, `aeac874d`, `1dd5706e`, merge `fe63ce18` | Initial retail/architecture review found 4 issues; first narrow re-review left 2 retry-transaction/order gaps; final narrow re-review PASS | Installed DAT enum table 5 proves `0x10000005 -> 0x21000004`, root `0x1000039A`, exact flat list/buttons/templates/dialog assets, and no viewport. Runtime remains the only selection owner; row sizing, modal priority/retry, restore ordering, reset/disposal, and explicit live-DAT skip/probe are covered. Branch full suite 13,796+5 skip; LA11 owns physical visual/live-ACE acceptance. | +| LA9 | **DONE + MERGED 2026-08-14** | `ff6ebb6a`, `3f688951`, `208a70ac`, merge `2198a0cc` | Initial integrity review found 5 issues; narrow re-review left one orphan-child publication race; final narrow re-review PASS | First-run installer validates four DATs, consumes strict v1 Bake JSONL, preserves/reverifies SHA+size+tool-version records, and co-publishes self-contained launcher+Bake. Cross-process install/publish locks plus durable nonce prevent post-recovery mutation across real parent-only hard kills on Windows/Linux. Branch full suite 13,799+4 skip; real retail-DAT bake remains LA11. | +| LA10 | **DONE + MERGED 2026-08-14** | `2d2a5b50`, `1955ca8a`, `09d84387`, merge `da4fb3de` | Initial architecture/security review found 10 crash, trust, integrity, cleanup, and lifecycle issues; first narrow re-review left one rollback-source P1; final narrow re-review PASS | Production feeds and redirects are HTTPS-only, fixture loopback trust is explicit, downloads and archives are bounded and verified, version activation and rollback are atomic, active sessions hold the cross-process update lease, and schema-v3 self-update recovery verifies every prior/replacement file before apply, rollback, or restart. Real Windows/Linux process tests cover kill boundaries, staging races, lease deferral, corrupt backups, junctions/symlinks, and fail-closed recovery. Branch gates: Core 302/302 and Launcher 29/29 on Windows/WSL, full Release 13,945+4 skip, win/linux self-contained publishes. Integrated LA0–LA10 gate: 13,972+5 skip. | +| LA11 | **AUTOMATED CLOSEOUT REVIEW-CLOSED + MERGED 2026-08-15 — USER GATE PENDING** | `f881e5b4`, `134edabe`, `accd01a0`, `9f9c1167`, merge `d39f3098` | Initial dual-lens review found 7 startup/evidence/safety issues; first narrow re-review left 2 PID-reuse/ZIP-mode gaps; final narrow re-review PASS | Strict isolated roots and process-local feed override compose one exact launcher path graph. Windows targeted CTRL_BREAK is group-isolated and preserves stdin; exact-PID/start-identity status validation, credential-value scanning, deterministic Unix-mode A/B fixtures, Windows/native-Linux helper safety, and the exact A–I operator script are implemented. Clean branch preflight passed 32/32 with 13,985 tests + 4 skips. Integrated clean-head preflight at `a22f5411` passed 32/32 with 14,012 tests + 5 skips and report SHA-256 `49f225bc6043b9256f17b7bf0f29df919c894b8355633077751fd279756470df`. No connected/UI/real-DAT row has run; campaign shipment and #397 closure remain pending the user gate. | diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md new file mode 100644 index 00000000..85bd2a60 --- /dev/null +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -0,0 +1,294 @@ +# Campaign CC — retail character creation + +**Status: CLOSED — USER-ACCEPTED 2026-08-16.** All seven slices (CC1-CC7) +REVIEW-CLOSED; the connected gate ran as one extended round (findings +GF-1..16, re-tests R2-1..8 / R3-1..9 / R4-1..4, fix batches A-G + closeout ++ two re-test rounds, final build `1.0.2-cc.o`) and the user declared +**"Gate pass!"** on 2026-08-16. The campaign's headline milestone — the +FIRST live character created by acdream against ACE — was reached +mid-round on build `1.0.2-cc.g`. CC7 (the final slice) +closed out the campaign's implementation: the Create button un-ghosts and +opens chargen for real, the full 0xF656/0xF643 flow is proven end-to-end +against a real WorldSession, the launcher status-payload cycle is proven +end-to-end against the real Launcher.Core tailer, and the connected-gate +script is written. CC7's dual-lens review returned PASS-with-items on both +lenses (findings F1-F9); the F1-F9 fix round closed it out (register row +AP-229, test-script corrections, an App-layer wiring pin, and two ledger +wording corrections — see the CC7 ledger row's own review-fix-round note). +The user's connected gate +(`docs/research/2026-08-16-campaign-cc-test-script.md`) is the campaign's +sole remaining acceptance step — no automated live character creation has +been run against ACE (see the script's own §CC-Not-Automated). +**Goal (user-set):** the full retail creation flow against local ACE — Create +button through a new character entering the world, 3D preview live, rejections +showing retail's dialogs — then stop for the user gate. +**Branch:** `claude/acdream-launcher-credentials-4d2f7c` +**Process:** Campaign LA's, binding (Sonnet implements, Opus dual-lens reviews +per slice, retail decomp is the oracle, register rows with deviations, +build+test green per slice, commits tagged `Campaign CC`). + +This plan embeds the 2026-08-15 recon facts (three parallel sweeps: retail +gmCG UI, chargen data+wire, acdream seams) so slices and future sessions need +no transcript access. `references/ACE` and `references/holtburger` are NOT in +this worktree (gitignored) — read them from the main checkout at +`C:\Users\erikn\source\repos\acdream\references\`. + +## Retail ground truth (recon summary — cite these in code) + +**Flow.** Create button (`0x100003A0`) → `QueueUIMode(0x1000000b)` → +`gmCharGenMainUI` (acclient.h:56232): ONE root layout, enum `0x10000039` via +GetDIDByEnum table 5 (our generic `RetailDataIdResolver` handles this), pages +as children. `ECGProgress`: Heritage=1 → Profession=2 → Skills=3 → +Appearance=4 → Town=5 → Summary=6. Nav dispatch +`gmCharGenMainUI::ListenToElementMessage@237025`: Back `0x100003c6` (at +Heritage → DoExit), Next `0x100003c7`, Finish `0x100003c8` (Summary only), +Help `0x100003c9`, Exit `0x100003ca` (→ `ID_CharGen_ExitWarning` confirm), +Random `0x100003cb` (on Summary → randomize warning first). Tab buttons +`0x100003ef..f4` jump pages freely (not validation-gated). Page roots: +Heritage `0x100003d1`, Profession `0x100003d2`, Skills `0x100003d3`, +Appearance `0x100003d4`, Town `0x100003d5`, Summary `0x100003d6`; progress +bar `0x100003ce`, master page `0x100003d0`. Per-page child ids are in the +recon-cited ctors: Heritage `InitializePage@143731` (13 race buttons + text +`0x100003c4`), Profession `@143010` (6 attribute sliders `0x100003e6..eb`, +avail/health/stam/mana `0x100003e2..e5`, template buttons resolved in +`UpdateProfession@142180`: Custom `0x100003d9`, Bowhunter/Swashbuckler/ +Lifecaster/Warmage/Wayfarer/Soldier `0x100003da..df`), Skills `@141911` +(listbox `0x100003f7`, credits `0x100002f3`, info `0x100003fb/fc`), +Appearance `@140032` (gender `0x100003a7/a8`, spins hair/eyes/nose/mouth/skin +`0x100003af..b3`, headgear/shirt/trousers/footwear `0x100003b5..b8`, zoom +`0x10000325/26`, rotate `0x10000323/24`, color wheel family +`0x1000030e..0x10000321`, viewport `0x100003bb`), Town `@137120` (Sanamar +`0x1000040b`, Holtburg `0x1000040d`, Yaraq `0x1000040e`, Shoushi +`0x1000040f`), Summary `@136566` (list `0x10000400`, name text `0x10000402` +with NameInputFilter, viewport `0x10000406`). + +**CharGenState** (acclient.h:40074): the model our Runtime owner mirrors — +heritage/gender, appearance strips+styles+colors+shades (f64 shades), +template + 6 attributes + credit budgets + per-attribute locks, 55-slot +skill advancement array + skill credits, name[33], startArea, setupID, +verificationState. Writers per page in the recon (SetHeritageGroup recomputes +budgets + ApplyTemplate + RandomizeStartArea; SetGender reapplies clothing +and UpdateTrueFacePal). + +**Finish** (`DoFinish(this, arg2)@236864`): trim+set name → empty name → +`ID_CharGen_NoNameWarning`, abort. **CORRECTED at the CC3 review-fix round +(F3) — the original line here (`remainingAtrbCredits > 0` → abort, "retail +FORCES full spend") was WRONG; retail does NOT force a full spend.** The +real gate is `arg2 != 0 && remainingAtrbCredits > 0`: the ordinary +Finish-button click passes `arg2 = 1` (@0x004E9579), and on unspent +credits shows `MakeCreditWarningDialog` and returns WITHOUT sending +(@0x004E91F2-0x004E9210) — but that dialog's own confirm handler +re-invokes `DoFinish(this, 0)` (@0x004E98BB), which SKIPS the credit check +entirely (`arg2 == 0`) and sends with the credits still unspent. ACE +accepts this — `ValidateAttributeCredits` only rejects a total that +EXCEEDS the max, never an under-spend. Then: verification state must be +UNDEF (no double submit) → set PENDING → `Proto_UI::SendCharGenResult@0x00546A70`. + +**Wire 0xF656** (`ACCharGenResult::CG_Pack@0x005C7200`, byte-identical to +ACE's `CharacterCreateInfo.Unpack`): account String16L FIRST (outside the +body), then u32 constant 1, u32 heritage, u32 gender, u32×3 eyes/nose/mouth +strips, u32×2 hairColor/eyeColor, u32 hairStyle, u32×2 headgearStyle/Color, +u32×2 shirt, u32×2 trousers, u32×2 footwear, f64×6 skin/hair/headgear/shirt/ +trousers/footwear shades, u32 templateNum, u32×6 attributes +(str/end/coord/quick/focus/self), u32 slot, u32 classID, u32 numSkills + +numSkills×u32 advancement classes (MUST be exactly 55 — ACE TERMINATES the +session on mismatch), String16L name, u32 startArea, u32 isAdmin, u32 +isEnvoy(=ACE IsSentinel), u32 trailing checksum = sum of +heritage+gender+strips(3)+hairColor+eyeColor+hairStyle+headgearStyle+ +shirtStyle+trousersStyle+footwearStyle+template+6 attributes (ACE never +reads it; we send it for byte fidelity). holtburger cross-check: +`character/types.rs:236` (stops before the checksum). + +**Response 0xF643** (shared opcode with restore — LA7a's conditional parse is +reusable): codes Undef=0 Ok=1 Pending=2 NameInUse=3 NameBanned=4 Corrupt=5 +DatabaseDown=6 AdminPrivilegeDenied=7. On Ok the payload is a +CharacterIdentity (guid, String16L name, u32 secondsGreyedOut) and NOBODY +sends a fresh CharacterList — retail appends the identity to its local +roster (`Handle_CharGenVerificationResponse@0x0055E8B0` case 1 → +`CharacterSet::AddIdentity`) and `gmCharGenMainUI::Update@236161` then +watches the set and calls `CPlayerSystem::LogOnCharacter` DIRECTLY when the +new name appears (logs straight in; only falls back to char management if it +never appears). Error dialogs, byte-decoded from +`gmCharGenMainUI::RecvNotice_CharGenVerificationResponse @0x004e9030`'s +switch + its `(arg2-1) > 6` unsigned-underflow guard and jump table +`@0x004e9150` (CC5 review-fix round F2, 2026-08-16 — corrects this +paragraph's earlier "Pending/Undef→silent state reset, retail swallows it" +claim, which was WRONG): Ok(1)→no dialog (closes any open dialog, marks +success, returns); **Pending(2)→`ID_Character_Err_NameDBDown`** (explicit +switch case, same label as Corrupt/DatabaseDown — NOT a silent reset); +NameInUse(3)→`ID_Character_Err_NameReserved`; NameBanned(4)→ +`ID_Character_Err_NameBanned`; Corrupt(5)/DatabaseDown(6)→ +`ID_Character_Err_NameDBDown`; AdminPrivilegeDenied(7)→ +`ID_Character_Err_NameAdminDenied`; **Undef(0) and any code outside 1..7 +fall through the unsigned-underflow default arm to the SAME +`ID_Character_Err_NameDBDown` dialog** (the switch never has a genuinely +silent branch — every non-Ok code shows a dialog). ACE sends Pending for a +disabled-Olthoi rejection (`CharacterHandler.CharacterCreateEx`, +`olthoi_play_disabled` branch); ported faithfully this now means that +rejection surfaces a visible NameDBDown dialog, which IS retail's actual +behavior — the previous "swallows it" reading made Finish a silent +no-op forever for that case instead. + +**Chargen DAT table** `0x0E000002`: readable TODAY via the +Chorizite.DatReaderWriter package (`dats.Get`) — zero in-tree +readers exist. ACE loaders (`ACE.DatLoader.FileTypes.CharGen` + +`HeritageGroupCG/SexCG/TemplateCG`) and retail serializers +(`ACCharGenData::Serialize@0x005C36D0`, `HeritageGroup_CG@0x005C2100`, +`Sex_CG@0x005C1600`, `Template_CG@0x005C0450`) define the shape: per +heritage → name/icon/setup/EnvironmentSetup/attribute+skill credits/start +areas/skills(costs)/templates(attrs+skills)/genders; per sex → scale, setup, +base palette, skin palset, base ObjDesc, and the option LISTS (hair styles/ +colors, eye colors, eye/nose/mouth strips, headgear/shirt/pants/footwear, +clothing colors). + +**3D preview** (`gmCG3DView`, Appearance `0x100003bb` + Summary `0x10000406` +ONLY — the other four pages have no viewport): preview body +`CPhysicsObj::makeObject(setupId)` (fallback HUMAN_SETUP_ID), rebuild on +change via ObjDesc (`ClothingTable::BuildObjDesc` per clothing slot + strips ++ PalSet skin/hair/eye subpalettes) applied with +`DoObjDescChangesFromDefault@242308`, one DISTANT_LIGHT (intensity 2.0), +idle animation loop at 30fps (`set_sequence_animation`), rest-pose freeze on +zoom-in, BUTTON-toggled continuous rotation (`DoRotation@137337`, 3.0 +s/revolution, per-frame global-message-3 tick), zoom tween between +per-heritage camera positions (`Update@138974` hard-codes Olthoi vs +human-form camera offsets). + +## acdream seams (build on these, do not reinvent) + +- Layout mount: `RetailDataIdResolver.Resolve(dats, 0x10000039, 5)` + + `LayoutImporter` — fully generic. `DatWidgetFactory` already maps dat type + 0xD → `UiViewport`. The char-management controller REFUSES viewports by + local policy (:212) — chargen gets its OWN controller; clone + `CharacterManagementUiMountCoordinator` + the bindings-record pattern. +- Fixed canvas: chargen is the same 800×600 flow screen — mount at authored + extent, `UiRoot.FixedCanvasSize` on activate (AD-98), dialogs center on + `EffectiveCanvasSize`. Live-DAT probe tests sweep ALL media ids + (`CharacterManagementLiveDatTests` pattern) and pin authored + justify/anchors. +- Preview pipeline: `PrivateEntityViewportRenderer` (offscreen target → + texture table → `UiViewport` sprite) is proven by paperdoll + appraisal; + cameras there are FIXED — chargen needs a heading-capable camera. NOTE: + `GlGpuDevice.RegisterExternalColorTexture` is a DELETED API that survives + only in stale doc comments — do not cite it. Appearance building: + `DollEntityBuilder.Build` is index-agnostic and pure (setup + resolved + palette/part ids), but the only existing factory reads a LIVE entity — + chargen needs a new index→dat→ObjDesc factory (SexCG.BaseObjDesc + strip + overlays + PalSet.GetPaletteID hues). Pose: paperdoll holds a static final + frame; retail chargen plays a live idle loop — see slice CC6 for the + staged approach. +- Runtime owner: mirror `RuntimeCharacterSelectionState` exactly (lifecycle/ + snapshot/delta records, borrow-only view, generation-gated commands, one + mutable owner, no App types). Command family lands beside + `IGameRuntimeCommands.CharacterSelection`. Enter-after-create hooks the + existing `LiveSessionController.BeginEnter/CompleteEnter`. +- Wire plumbing: `WorldSession`'s dispatch chain routes EVERY 0xF643 through + `CharacterRestore.Parse` today with no request correlation — the KNOWN + LANDMINE. Creation requires an awaiting-request latch (create vs restore) + BEFORE its response arm lands. Outbound mirrors + `SendRestoreCharacter@2223`. Status writer: add `characterCreated` / + `creationFailed` events (update the pinned §LA1 contract text + the + Launcher.Core tailer + tests in lockstep). + +## Slices + +| Slice | Deliverable | Depends | +|---|---|---| +| CC1 | Chargen data layer: `CharGen` table reader → typed options model (heritages/sexes/appearance lists/templates/skills+costs/budgets/towns), Content/Core, live-DAT probes | — | +| CC2 | Wire: `CharacterCreate` 0xF656 builder (byte-exact incl. checksum), shared verification-response type (refactor from `CharacterRestore`), WorldSession request-correlation for 0xF643, send seam, status events + contract/tailer update | — | +| CC3 | `RuntimeCharacterCreationState`: full CharGenState mirror, per-page commands, retail client gates (full-spend, name, 55-slot invariant, client-side slot cap), verification latch, Ok → roster append + retail log-straight-in | CC1, CC2 | +| CC4 | Screen shell + form pages (App): mount (enum 0x10000039), master nav/tabs/progress, dialogs, Heritage + Profession + Skills + Town pages | CC1, CC3 | +| CC5 | Summary page: name input (NameInputFilter, `ID_CharGen_NameTooLong`), summary listbox, static summary viewport, Finish gates + full response/dialog handling. **CC6b-MOUNT review fix round F12 amendment (2026-08-15):** Finish gates MUST add a heritage/gender refusal to `RuntimeCharacterCreationState.TryBeginFinish` — with AD-101 retired, a caller can hold `_genderKey == 0` (or, before a real heritage/gender selection, `_heritageId == 0`) all the way to Finish, and `TryBeginFinish`'s current four refusals (NoName/AttributeCreditsUnspent/AlreadyPending/RosterFull) have no gate for either — see AP-214's own noted latent-interaction risk. This slice MUST ALSO land a real `RandomizeCharacter` port (the shared AP-214/AP-212 primitive gap) BEFORE the connected user gate opens Finish for real use — the reviewer's requirement, not optional polish: retail's `gmCharGenMainUI` ctor rolls a full character before any page constructs (AP-214), so a heritage/gender check alone does not reproduce retail's actual guarantee that Finish is never reachable with an unset heritage/gender; only porting `RandomizeCharacter` closes that gap the way retail's own architecture does. | CC3, CC4 | +| CC6 | Appearance page + preview: index→ObjDesc factory, chargen preview renderer (offscreen, heading camera, rotate/zoom buttons), spin controls + color wheels; **staged:** CC6a static-pose preview (paperdoll-style held frame, register row for the missing idle loop), CC6b idle animation + zoom rest-freeze (retire the row) | CC1, CC4 | +| CC7 | End-to-end: Create button un-ghosts, full flow vs ACE shapes in tests, launcher payload cycle, connected checklist doc | all | + +Parallelism: CC1 ∥ CC2 (disjoint: Content/Core vs Core.Net; separate +worktrees). CC4 ∥ CC6a after CC3. CC5 last before CC7. + +## Risks / open items (from recon Unknowns) + +1. 0xF643 create/restore correlation (CC2's first job; the restore doc + comment already warns). +2. 55-slot skill array: ACE terminates the session on mismatch — CC2/CC3 + must make it structurally impossible to send anything else. +3. Slot cap is client-enforced only (ACE never checks on create) — honor + `slotCount` like retail's UI did. +4. Color-wheel/gradient widgets (`tagColorWheel`, GradCircle `0x1000030e`, + shade scroll) may need new widget types in `DatWidgetFactory` — CC6 + scouts the authored layout first. +5. Retail unknowns to resolve during slices, never guess: the chargen + please-wait dialog context (decompiler-mislabeled field), the + AppearancePage gender-flip-on-init oddity (@140355 — verify live before + porting), `Method_CG` enums are empty in the header, ZoomIn tween + duration constant is decompiler-garbled (measure against retail if it + matters). +6. Viewport inside the fixed canvas: the offscreen target's pixel size vs + the canvas-scaled on-screen rect (render at scaled size for crispness or + authored size for fidelity) — decide in CC6a with the user gate as + arbiter. +7. `references/*` absent in worktrees (except WorldBuilder, uninitialized + submodule) — agents read ACE/holtburger from the MAIN checkout path. +8. **CC7 landmine (found in the CC1 review fix round, 2026-08-15):** ACE's + `PlayerFactory.CreatePlayer` heritage-override branch + (references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:184-211) + over-deducts skill credits when specializing a skill the active + heritage's own list prices. For a skill priced ONLY by the global + SkillTable, ACE correctly computes the incremental specialize cost via + `SkillBase.UpgradeCostFromTrainedToSpecialized` (= `SpecializedCost - + TrainedCost`) and charges `TrainSkill(trainedCost) + + SpecializeSkill(incrementalCost)` = the field's TOTAL, matching retail. + But when the heritage's own list has an entry, ACE sets + `specializedCost = skillGroup.PrimaryCost` directly — `PrimaryCost` is + already the TOTAL cost to reach Specialized (acdream's own + `ChargenSkillCost.PrimaryCost` convention, confirmed against retail) — + and then still charges `TrainSkill(NormalCost) + + SpecializeSkill(PrimaryCost)`, over-deducting by an extra `NormalCost` + credits versus what retail's client computed and what the player agreed + to spend. Practical impact for CC7's connected gate: a retail-legal + character build that specializes a skill the ACTIVE HERITAGE prices + (every one of the 13 installed heritages has exactly one such skill — + see `ChargenTableReaderInstalledDatTests.InstalledHeritages_SkillCostFallbackCoversTheKnownUncostableSkillSet`) + may be REJECTED by local ACE with `FailedToSpecializeSkill` even though + acdream sent the byte-correct 0xF656 body. If CC7's gate hits this, + it is an ACE-side bug reproduced from its own source, NOT an acdream + wire or math defect — do not "fix" acdream's cost math to match ACE's + over-deduction. **MEASURED 2026-08-15 (user-prompted — downgrades this + landmine to LATENT):** dumping the installed EoR DAT shows every one of + the 13 heritages' single override is skill 14 (Arcane Lore) at + NormalCost=0 / PrimaryCost=2, versus global TrainedCost=4 / + SpecializedCost=6. ACE's over-deduction equals NormalCost — which is + ZERO for the only heritage-priced skill — so ACE charges 0+2=2 and + retail's client computes 2: they AGREE, and no character build can + trigger the rejection with end-of-retail data. The formula bug in ACE's + heritage-override branch is real but unfireable here; it only matters + if a custom server ships a DAT whose heritage override has a nonzero + NormalCost. The earlier "may be REJECTED" inference was made from code + without measuring the data — the C4 closeout's observe-don't-infer + lesson, again. Register: file an AD row if CC7 needs a documented + workaround (e.g. picking a Specialized skill combination that avoids + the heritage-priced skill for the connected gate) rather than silently + adjusting acdream's send. + +## Review protocol + +Per slice: implement → Opus dual-lens (architectural + retail fidelity — this +campaign is retail-heavy everywhere) → fixes → narrow re-review → DONE in +ledger. CC2's review adds wire-byte scrutiny (the LA7a precedent: the +reviewer decodes the binary); CC6's adds the visual-fidelity lens ahead of +the user gate. + +## Ledger + +| Slice | Status | Commits | Review | Notes | +|---|---|---|---|---| +| CC1 | REVIEW-CLOSED 2026-08-15 | `04450041`, `cb4703e8` | CLOSED (fix round + narrow re-review; every citation independently re-derived) | Core model (no Chorizite leak) + Content projector; 31 math units + 6 installed-DAT gates (13 heritages). FINDING for CC3: each human heritage's "Adventurer" template IS retail's Custom entry point — attributes at the 10-floor (60/330), a real TemplateCG row, not a UI special case. **Review fix round (`cb4703e8`):** F1 doc corrected — Custom IS template index 0 (the Adventurer row), per `gmCGProfessionPage::UpdateProfession @ 0x004821b0` (case 0 → button 0x100003d9 / `ID_CharGen_CustomText`) and `CharGenState::SetTemplate @ 0x005C5A60` (commits via `CharGenState::ApplyTemplate @ 0x005C5080`, i.e. selecting Custom resets sliders to the floor spread, it does not bypass templates); F2 two-tier skill-cost fallback implemented (`ChargenOptions.GlobalSkillCostsBySkillId` from portal.dat 0x0E000004, `ChargenSkillCreditMath` checks heritage list then global list) + installed-DAT completeness assertion recording reality: the global SkillTable prices 38/54 advancement skill ids, every one of the 13 heritages ships EXACTLY one heritage-specific override (always also present in the global table), and 16 skill ids are genuinely uncostable in both tiers (retail's -1 case) — see `ChargenTableReaderInstalledDatTests.InstalledHeritages_SkillCostFallbackCoversTheKnownUncostableSkillSet`; F3 every `ChargenTableReader` collection is now frozen at projection (`ToFrozenDictionary`/`ToArray`, matching `MagicCatalog`'s pattern) including both `ChargenOptions.Empty` dictionaries; F4 a reflection guard test (`ChargenNoChoriziteLeakTests`) pins the no-Chorizite-leak contract by walking every public `AcDream.Core.CharGen` member; F5 `HasAnyAppearanceOptions`'s doc reworded to state precisely what it proves (an OR across eight lists, omitting the three color lists) + a new installed-DAT gate records per-list reality — found COMPLETE, every gender of every heritage has non-empty lists across all eight plus the three color lists, even the sparse Gear Knight/Olthoi variants; F6 `TryGetHeritage`/`TryGetStarterArea` annotated `[MaybeNullWhen(false)]` (matching the house `EmptyDatReaderWriter` pattern), all affected call sites (more than the originally estimated five) fixed across both test projects. Filed CC7 risk item 8: ACE's `PlayerFactory` heritage-override branch over-deducts skill credits when specializing a heritage-priced skill (references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:184-211) — a retail-legal build may be rejected by local ACE at the CC7 connected gate; this is an ACE bug, not an acdream defect. **Narrow re-review CLOSED:** the reviewer retro-graded F2 to HIGH (under the base commit 37 of 38 costable skills were charged zero) and confirmed the SkillBase.SpecializedCost->PrimaryCost mapping dodged the UpgradeCostFromTrainedToSpecialized trap. Residuals: R1 retail refunds +1 credit on a both-tier miss (port charges 0; unreachable via retail’s own skills listbox — NOTE FOR CC3 if any path ever exposes the 16 uncostable ids); R2 list downcast-mutability and R3 field-walking in the leak guard CLOSED at the merge-closeout commit (Array.AsReadOnly at every projection seam; GetFields walk added). Decomp fact for CC4: ApplyTemplate force-sets template_=0 for heritage 0xc/0xd — both Olthoi variants are hard-locked to Custom/template 0. | +| CC2 | REVIEW-CLOSED, MERGED 2026-08-15 (`55fc51ed`) | `5eaad2c8`, `e77ebf10`, `95e95bb6` | PASS then CLOSED (fix round: F1 latch-scope narrowing + overwrite pin test, F2 register AD-100, F3 ACE double-NameInUse note, F4 creationFailed{code,reason,name}, F5 pointer, retail-discriminator citations) | Byte-exact 0xF656 (19-term checksum vs CG_Pack accumulator), shared 0xF643 type, correlation latch, status events + contract amendment. Core.Net 993 / Runtime 1667 / Launcher.Core 323, Windows+WSL | +| CC3 | REVIEW-CLOSED 2026-08-15 | `9a84230c`, `397ccd62`, + the R1 closeout commit | CLOSED (dual-lens: retail fidelity PASS, architectural FAIL → F1-F16 fix round `397ccd62` → narrow re-review CLOSED, both lenses PASS. Re-review residual R1 — the cached wire count is stale by creates-since-last-CharacterList, so a SECOND create after a rejected enter got wire slot N instead of N+1 — fixed in the closeout commit: `LiveSessionController._createsSinceCharacterList` (reset on every fresh wire CharacterList apply + generation reset; applied only to the cached-wire branch — the display-roster fallback already counts prior appends), regression test `SecondCreate_AfterRejectedEnter_GetsTheNextWireSlot` drives create→Ok→rejected guid-enter→ReturnToSelection→second create and pins slots 0/1/2/3. R2: fix-round sha recorded here.) | `RuntimeCharacterCreationState` (new, `src/AcDream.Runtime/Session/`): full CharGenState mirror (heritage/gender/appearance/template/six attributes+locks/55-slot skill set/name/startArea/slot/verification state), mirroring `RuntimeCharacterSelectionState`'s exact pattern (snapshot/delta/event-stream/borrow-only view, generation-gated `Try*` internals). Ports `SetHeritageGroup`, `SetGender`, `SetTemplate`/`ApplyTemplate` (Custom = template 0, Olthoi force-lock), the six attribute setters + `GetAbsRemainingCredits` + `BalanceAttributes` (retail's literal str/end/coord/quick/focus/self round-robin order, cursor-based fairness), `SetSkillLevel` + `ResetSkillLevels`' three-way free-skill baseline (both two-tier cost lookups reuse CC1's `ChargenSkillCreditMath`/`ChargenSkillCost` verbatim — no duplicated math), `RandomizeStartArea`, and `DoFinish`'s complete gate sequence (empty name / unspent attribute credits [see F3 below] / already-Pending / client-side roster-vs-slotCount cap). `LiveSessionController` gained a sibling `IRuntimeCharacterCreationCommands` implementation (command family lands beside `IRuntimeCharacterSelectionCommands`, `IGameRuntimeCommands.CharacterCreation` added with the same default-throw shape as `CharacterSelection`), a `CharacterCreationState` property, `ILiveSessionOperations.CreateCharacter` (default method → `WorldSession.SendCharacterCreation`), and a `HandleCharacterCreationResponse` wire handler subscribed to `WorldSession.CharacterCreateResponseReceived` alongside the existing character-selection bindings. `ILiveSessionLifecycleHost` gained `ApplyCharacterCreated`/`ApplyCreationFailed` as DEFAULT interface methods (no-op) so `AcDream.App`'s existing host implementations keep compiling unchanged — wiring them to `SessionStatusWriter.CharacterCreated`/`CreationFailed` is left to CC4 (Runtime calls the hooks; the App-side forward is a future host-construction change; **F14: zero production call sites exist for these hooks until then — a headless bot cannot observe a create yet**). **Review fix round (this commit):** F1 (HIGH, blocking) the post-create log-straight-in no longer enters by roster INDEX — `WorldSession` gained a guid-based `EnterWorld(uint characterGuid, string accountName, TimeSpan?)` overload (refactored to share `EnterWorldCore` with the index-based overload) plus `ILiveSessionOperations.EnterWorldByGuid` (default method); `LiveSessionController` factored `EnterSelectedCore`/the new `EnterCreatedCharacterCore` through a shared `EnterHighlightedCore(sendEnterWorld)` — the cached wire `CharacterList` is stale for a just-created character by ACE design (ACE appends server-side and replies Ok with no CharacterList resend — `references/ACE/.../CharacterHandler.cs:170-172`), so an index-derived enter could throw (0 pre-existing characters) or enter the WRONG character (N pre-existing, display order ≠ wire order). F2 (HIGH, blocking) the post-create roster append no longer round-trips through `ApplyRoster` (which re-derives EVERY entry's `ActiveIndex` — a wire contract ACE indexes for delete, `CharacterHandler.cs:297` — from display/name-sort order); `RuntimeCharacterSelectionState` gained a real `AppendCreatedCharacter(characterId, name, wireIndex)` primitive that preserves every existing entry's `ActiveIndex` untouched and assigns the new entry's from the pre-create wire `CharacterList.Characters.Count` (0-based, read from the same cached source the index-enter path uses). F3 (MEDIUM-HIGH, blocking) the credit gate was NOT retail — `DoFinish(this, arg2)`'s real gate is `arg2 != 0 && remainingAtrbCredits > 0`: the ordinary click (`arg2=1`) warns-and-refuses, but the warning dialog's own confirm re-invokes `DoFinish(this, 0)`, which skips the check and sends with credits unspent (ACE accepts this). `TryBeginFinish`/`LiveSessionController.Finish`/`IRuntimeCharacterCreationCommands.Finish` gained a `confirmedUnspentCredits`/`confirmUnspentCredits` parameter (default `false` = retail's `arg2=1`) — the plan doc's own "retail FORCES full spend" line above (§Retail ground truth, Finish) was corrected in the same round. F4 (MEDIUM, blocking) a stale out-of-range template index surviving a heritage switch to a heritage with fewer templates now clears to `TemplateUnset` in `ApplyTemplateLocked`, mirroring `ConstrainAllByHeritage @ 0x005C65CC`'s `template_ >= count → template_ = 0xffffffff` clamp (previously it just returned, leaving the stale index to reach the wire). F5 (MEDIUM) AP-207's anchor was wrong (`SetAttribValue` never calls `FitTemplateToCharacter`) — corrected to the four real call sites, including a fourth the original filing also missed (`UpdateToDefaultAttributes @ 0x00482860`). F6 (MEDIUM) `ApplyCreationResponse`'s Pending/Undef branch no longer publishes from inside `lock(_gate)` — every branch now sets `kind` and a single `Publish` runs after the lock releases, matching every sibling method. F7 (MEDIUM) two new tests pin `BalanceAttributes`' persistent cursor: successive overspends absorb from different attributes, and the Self→Strength wrap. F8 (LOW) `ResetSkillLevels`' doc corrected — retail's real gate is BOTH costs `>= 0` (not "either tier"); the dictionary-presence equivalence is a CC1-established, installed-DAT-gated invariant, cited precisely. F9 (LOW) the `Slot` doc corrected — retail DOES assign it (`gmCharacterManagementUI::SelectCharacter @ 0x004EC160` → `SetSlot(GetSlot(...))`), just semantically stale (the last-selected PRE-EXISTING character's slot); conclusion (send 0) unchanged. F10 (LOW) AP-209's `classID` citation completed with the three heritage-dependent branch ids (ordinary/Olthoi/OlthoiAcid) plus admin variants. F11 the integration test fixture no longer stubs `EnterWorld` to a bare counter — it captures guid-based calls and the fixture now has two pre-existing characters whose wire order deliberately differs from alphabetical order, so the roster-preservation assertion actually exercises F2 instead of coinciding with it by accident. F12 filed register row AP-211 for the client-side `RosterFull` slot-cap refusal (acdream-side gate, no retail `DoFinish`-layer counterpart — same-commit rule). F13 `LiveSessionController.Finish`'s bare `catch {}` narrowed to `InvalidOperationException`/`SocketException` and `_scope` bound to a local after validation. F15 `RandomizeStartAreaLocked` now leaves `_startArea` unchanged on an empty list (matching retail's `if (var_9c > 0)` guard) instead of forcing `-1`. Filed register rows AP-207 (FitTemplateToCharacter's FPU-unrecoverable auto-detect skipped — ACE only reads `TemplateOption` for title text; anchor corrected this round), AP-208 (per-style color-count approximated by the shared gender-wide `ClothingColors` list — CC1's model has no per-style palette data), AP-209 (`classID` sent as a placeholder `0` — DAT DID lookup unavailable in Core, ACE ignores the field; branch table added this round), AP-210 (`ApplyTemplate`'s per-attribute guarded sequential set approximated as one atomic replace), AP-211 (this round — the `RosterFull` client-side slot-cap refusal). Tests: `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (34 cases — every Finish gate including the F3 confirmed-credits path, the F4 stale-template clamp, the F7 cursor-advance/wrap pair, Ok/each-rejection-code response mapping, duplicate-NameInUse tolerance, Olthoi template lock, attribute-lock/balance interaction, uncostable-skill rejection, generation reset) + `.../Session/LiveSessionControllerCharacterCreationTests.cs` (5 cases — wire-send exactly 55 skill slots via a REAL `WorldSession` + `GameMessageCapture`, decoded byte-for-byte; the full Ok round trip via `WorldSession.ProcessDatagram` reflection asserting F1's guid-based enter + F2's ActiveIndex-preserving roster append + `ApplyCharacterCreated`; the NameInUse round trip asserting `ApplyCreationFailed` + no roster/enter side effect; the local-refusal-never-touches-the-wire gate; the F3 confirmed-unspent-credits send). Runtime 1706/0 (was 1701, was 1667), Core.Net unchanged at 994/0, full solution Release build green. OPEN for CC4+: `RuntimeCharacterCreationState`'s `ChargenOptions` currently defaults to `ChargenOptions.Empty` — threading the installed DAT's loaded options through `GameRuntime`/App startup is unresolved; the `Slot` field's real assignment source (which caller picks the target roster slot) has no decomp citation (ACE ignores it, non-load-bearing); `classID`'s real DAT-DID resolution (AP-209) if a non-ACE server ever needs it; the F14 zero-call-site status hooks. | +| CC4 | REVIEW-CLOSED 2026-08-15 | `0e71d3b8`, `ec854db0`, `8add0667`, + the R5 closeout commit | CLOSED after two fix rounds + final re-review (R1 arbiter CLOSED; R5 — the chargen root extent pinned 800x600 by live-DAT observation in the closeout commit, closing the mismatch-throw crash premise). Original verdict: architectural FAIL (F1, F6) + retail-fidelity PASS-with-reservations (F2, F3, F4) + LOW findings F5/F7-F12 (F13 is a merge-mechanics note for the orchestrator, not an acdream defect). Fix round applied same-session (see the "Review fix round" paragraph at the end of this row); re-review status owed to the orchestrator. | Screen shell + form pages (App layer). **Mount:** `CharacterCreationUiController`/`CharacterCreationUiMountCoordinator` (`src/AcDream.App/UI/Layout/`) clone `CharacterManagementUiController`'s recipe — enum `0x10000039` via `RetailDataIdResolver.Resolve(dats, ..., 5u)`, root `0x100003CC` (decomp-verified: `gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0`, NOT the plan doc's earlier `0x100003cc`-adjacent guesses — confirmed live against the installed DAT, `[CC4-DAT] enum=0x10000039 -> DID=0x21000038`), fixed-canvas AD-98 treatment shared with char-management. **CORRECTED at the review fix round (2026-08-15, F1) — the original claim above was FALSE**: `CharacterManagementUiController` does NOT do a per-tick set; it writes `UiRoot.FixedCanvasSize` ONCE on its own activation edge and NULLS it in both `Deactivate()` and `Dispose()`. This controller now matches that exact shape: `Open()` sets the canvas once, `Close()`/`Deactivate()`/`Dispose()` null it symmetrically. The un-nulled canvas was a real bug: `RuntimeCharacterCreationState` had no `CompleteEnter()` analogue to `RuntimeCharacterSelectionState`'s (added this round, wired at both `LiveSessionController` in-world edges), so the chargen view reported `IsActive=true` for an entire in-world session, and since `RetailUiRuntime.Tick` ticks char-management BEFORE chargen, chargen's un-nulled canvas would silently re-pin an 800x600 scale over the in-world UI forever once the screen had ever been opened (dormant at defaults, armed under `ACDREAM_OPEN_CHARGEN=1`). **Master shell:** progress bar `0x100003ce`, master page `0x100003d0` (state `0x10000025+page-1`), 6 page roots, 6 free-navigation tabs (`0x100003ef..f4`), nav buttons `0x100003c6..cb` — full decomp port of `gmCharGenMainUI::ListenToElementMessage @ 0x004e9450` (Back-at-Heritage→DoExit, Next capped at Summary, Finish Summary-only) and `SetProgressState @ 0x004e7a10` (the Olthoi Profession/Skills/Town tab-hide + forward/backward page redirect, keyed off the LIVE snapshot heritage id every call). Exit confirmation via `RetailDialogFactory.MakeConfirmation` + `ID_CharGen_ExitWarning` (table `0x23000002`, matching `DoExit @ 0x004e8650`); on confirm the screen just closes (visibility only — see AD-99's sibling precedent) rather than porting `gmEpilogueUI`. **Heritage page** (`CharacterCreationHeritagePage.cs`, decomp `InitializePage @ 0x00483a10` + the EXACT button-id→heritage-id map read off `ListenToElementMessage @ 0x00483860`, which is NOT numeric-order — e.g. `0x100005e8`→Tumerok(7)): all 13 buttons, composed description text (`ID_CharGen_Heritage_StartingSkills_Header/Body`, `ID_CharGen_Heritage_BonusSkills_Trained_Header` + per-heritage body — Shadowbound/Penumbraen share one string per the decomp's `case 5: case 0xa:`; Lugian/Olthoi/OlthoiAcid have no bonus-skills string in the retail table at all, confirmed by string-key absence, not guessed). Selecting a heritage ALSO auto-selects its lowest gender key (AD-101 — Appearance's real gender buttons are CC6b's). **Profession page** (`CharacterCreationProfessionPage.cs`, `InitializePage @ 0x00482d50` + `UpdateProfession @ 0x004821b0`'s template map, cited already on `ChargenTemplate`): 7 template buttons (Custom=index 0, the six presets NOT in id order), 6 attribute sliders with the exact e6/e7/e9/e8/ea/eb id↔attribute-id mapping (the documented 3/4 swap), avail/health/stamina/mana. Live-DAT probe found TWO widget-mapping surprises the decomp's `DynamicCast` calls don't predict: the slider's value display (`0x100002ef`) imports as `UiField` not `UiText` (retail's `NumberInputFilter`, `@0x00482e36`) — wired for direct numeric entry via `OnSubmit`, not just display; and all four avail/health/stamina/mana containers (and the Skills credits meter) author as `UIElement_Button` whose Type-12 value child is swallowed by `UiButton.ConsumesDatChildren` before ever becoming an addressable widget — substituted with the button's own `.Label` (AD-103). Health/Stamina/Mana formulas ported from `UpdateAttributeValues @ 0x00482450`: Health=Endurance/2 (int truncation — the decompiler elides the FPU divide at `_ftol2 @0x0048262b`, so the exact MSVC rounding mode is UNVERIFIED beyond well-established AC convention; flagged, not guessed-and-hidden), Stamina=Endurance, Mana=Self; Available=`RemainingAttributeCredits` directly (`UpdateCreditsMeter`-style, no formula). **Skills page** (`CharacterCreationSkillsPage.cs`, `InitializePage @ 0x00481dd0`): ONE flat listbox (AP-213, retail's four-bucket sorted `InsertEntrySorted`/`UpdateSkillEntry` model not ported) driven by CC3's `TrainSkill`/`SpecializeSkill`/`UntrainSkill` + the SAME two-tier `TryGetSkillCost` presence gate `RuntimeCharacterCreationState` uses (16 uncostable ids never listed, matching retail); credits meter via the AD-103 button-Label substitution; info panes `0x100003fb/fc` unbound (no info-pane content source this round). **Town page** (`CharacterCreationTownPage.cs`, `InitializePage @ 0x0047c6d0` + `SetTown @ 0x0047c360`'s literal index map): the four buttons map to LITERAL `startArea` indices (Sanamar→3, Holtburg→0, Yaraq→2, Shoushi→1 — not id order), composed "How To" + per-town description text. **Random** (`0x100003cb`, `DoRandom @ 0x004e7d70`): Heritage/Profession/Town approximated with a uniform pick over every valid option (AP-212 — no `RandomizeHeritageGroup`/`RandomizeTemplate` primitives exist); disabled outright on Skills (no `RandomizeSkills` primitive), Appearance (placeholder), Summary (CC5's warning dialog). **Options threading:** `RuntimeCharacterCreationState.InstallOptions(ChargenOptions)` (new, mirrors `RuntimeCharacterState.InstallSpellMetadata`→`Spellbook.InstallMetadata`'s "install immutable DAT metadata after construction, throw if already active" pattern) called from `ContentEffectsAudioCompositionPhase.Compose` (new `ChargenOptionsInstalled` composition point, right after `SpellMetadataInstalled`) via `IContentEffectsAudioCompositionFactory.LoadChargenOptions`/`InstallChargenOptions` — `ChargenTableReader.Load(dats)` threaded through the SAME DAT-open composition sequence spell metadata uses, always well before any session's `Begin()`. **CORRECTED at the review fix round (2026-08-15, F6)**: the original claim that headless was unaffected left a dead end — `HeadlessSessionHost` wired the `CharacterCreated`/`CreationFailed` status hooks (closing CC3's F14) but never installed `ChargenOptions`, so a content-bearing headless host could observe a create but never actually issue one (every chargen command silently refused against `ChargenOptions.Empty`). Fixed by installing options directly beside the existing `InstallSpellMetadata` call, off the same `HeadlessProcessContentLease.Dats`, whenever `contentLease` is non-null; a content-less headless host (a validated-legal configuration — see the R9 note near `_contentLease`'s other reads) still cannot issue chargen commands, matching its existing inability to resolve spell/collision data either. **Status hooks:** `LiveSessionLifecycleBindings` gained optional `CharacterCreated`/`CreationFailed` delegates (default `null` — every pre-CC4 construction site keeps compiling); `LiveSessionLifecycleHost` now overrides both `ILiveSessionLifecycleHost` methods to forward them; `LiveSessionHostBindings` gained matching optional fields threaded through `LiveSessionHost`'s constructor; both `LiveSessionRuntimeFactory.Create` (App/graphical) and `HeadlessSessionHost` wire them to `SessionStatusWriter.CharacterCreated`/`CreationFailed`, closing CC3's F14 (zero call sites). **Deferred command seam:** `IGameRuntimeView.CharacterCreation` (new default-throw member, mirrors `CharacterSelection`), `GameRuntime.CharacterCreation` (passthrough to `Session.CharacterCreation`), `CurrentGameRuntimeAdapter`'s new `CharacterCreationProjection` (IsActive-gated view+command wrapper, mirrors `CharacterSelectionProjection`), `DeferredGameRuntimeStateCommands`'s new `CharacterCreation` view getter + 9 generation-capturing wrapper methods, and `CharacterCreationRuntimeBindings` wired in `InteractionRetainedUiComposition.cs` (`CharacterCreation:` sibling of `CharacterSelection:`, `ResolveText` backed by a `DatStringResolver` cached once per composition (`characterCreationStrings`, review fix round F12 — a fresh resolver per call was allocating + re-locking on every Heritage/Town description lookup, several times per page switch) and locked under `d.DatLock` only around each `.Resolve` call, `OpenOnStart` from the new `RuntimeOptions.OpenCharacterCreationOnStart` / `ACDREAM_OPEN_CHARGEN=1` env flag — the interim open seam since Create stays ghosted). **Widget types added to `DatWidgetFactory`: NONE** — every id resolves through EXISTING factory mappings (Button=1, Text/Field=12, Scrollbar=11, ListBox=5); the two "new" findings (editable-Field slider value, button-consumed credits/vitals children) are AUTHORED-DATA-DRIVEN outcomes of the existing factory logic, not new widget classes. **Register rows filed (same commit):** AD-101 (Heritage-page auto-gender-select interim default), AD-102 (Viamontian/Sanamar ToD-account-ownership gate omitted — acdream has no account/DLC signal), AD-103 (avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays), AP-212 (Random button's uniform-pick approximation), AP-213 (Skills page flat-listbox simplification), TS-82 (Appearance/Summary placeholder pages, reachable via free tab nav, content-inert pending CC5/CC6a/CC6b). **Tests:** `tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs` (7 cases, `ACDREAM_PROBE_LIVE_MOUNT=1`-gated — sweeps every master-shell/page id against the installed DAT and pins the two widget-mapping surprises above) + `CharacterCreationUiControllerTests.cs` (16 cases — hand-built layout fixture, no DAT: page switching, Olthoi tab-hide+redirect, Back/Exit/Random gating, exit-confirm/cancel, per-page command dispatch including the slider/field/skill-row/town-button paths) + `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (+4 `InstallOptions` cases) + `tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs` (+2 status-hook forwarding cases). Runtime 1713/0 (was 1707), App 5117/13 skips (was 5101/6, +16 new +7 gated-skip), Headless 165/0 unaffected, full solution Release build green. **OPEN for CC5/CC6a/CC6b:** the real Appearance-page gender buttons must retire AD-101's auto-select; Summary's Finish gate, name input, and randomize-warning dialog (currently Finish/Random both hard-disabled); Skills page info-panes `0x100003fb/fc` have no content source wired yet; the four-bucket sorted skill list (AP-213) and retail's exact Random algorithms (AP-212) remain unported if a future gate demands byte-exact parity; the Health/Stamina/Mana rounding-mode residual (see above) would need a live cdb byte trace to fully pin. **Review fix round (this commit, 2026-08-15):** F1 (HIGH, blocking, architectural) — see the corrected FixedCanvasSize paragraph above; added `RuntimeCharacterCreationState.CompleteEnter()` (mirrors `RuntimeCharacterSelectionState`'s own, wired at both `LiveSessionController` in-world edges: `StartCore` and the shared `EnterHighlightedCore`) and made `CharacterCreationUiController.Open`/`Close`/`Deactivate`/`Dispose` set/null `UiRoot.FixedCanvasSize` symmetrically with `CharacterManagementUiController`'s real (not per-tick) shape; added FixedCanvasSize coverage to `CharacterCreationUiControllerTests`. F2 (MEDIUM-HIGH, blocking, fidelity) — the attribute-slider scalar mapping was NOT retail's: fixed the display scalar to `value/100f` (`UpdateAttributeValues @ 0x0048251d`) and the drag inverse to `Math.Max(10, (int)(scalar*100f))` — truncate, clamp low only, no rescale (`ListenToElementMessage @ 0x004829c0`'s scrollbar-drag case, independently re-derived against the decomp and confirmed byte-for-byte); added tests at scalar 0.5 and 0.0 (the previous single scalar=1f test coincidentally agreed with both the old wrong formula and the new correct one). F3 (MEDIUM, blocking, fidelity) — ported `ListenToElementMessage @ 0x004e9450`'s heritage-button tab-restore arm (independently re-derived from the decomp: SHOW ids `0x100003bf/c1/c2/c3/10000590/91/100005a9/bf/c4/e8`, HIDE ids `0x100005c7/c8`, with Lugian `0x100005f1` genuinely absent from both switch cases — a real retail quirk, reproduced faithfully) as `CharacterCreationUiController.ApplyHeritageTabRestore`, invoked synchronously from a new `CharacterCreationHeritagePage` ctor callback on every button click; added restore-after-Olthoi-hide and Lugian-no-restore tests. F4 (MEDIUM, fidelity, blocks the user gate) — `gmCGTownPage::SetTown @ 0x0047c360` also sets the TOWN PAGE's own retail state (a separate literal map from the master page's per-page-index cycling: Holtburg->0x10000034, Shoushi->0x10000037, Yaraq->0x10000036, Sanamar->0x10000035, re-asserted directly at the Sanamar-click site `@0x0047c518`) — independently re-derived from the decomp's tail-merged-branch pattern and ported to `CharacterCreationTownPage.Refresh` via the existing `IUiDatStateful.TrySetRetailState` seam; added a test. F5 (MEDIUM) — AD-103's "composited pixel result unchanged" claim was asserted, not measured; softened to state the equivalence is unverified rather than building a rect/justify comparison probe this round. F6 (MEDIUM, blocking, architectural) — **decision: install `ChargenOptions` in the headless content path (option (a) of the two offered), not the deferred/out-of-scope alternative** — `HeadlessSessionHost` now calls `RuntimeCharacterCreationState.InstallOptions(ChargenTableReader.Load(content.Dats))` beside the existing `InstallSpellMetadata` call whenever `contentLease` is non-null, closing the gap where CC3's F14 status hooks were wired but no content-bearing headless host could ever produce a create to observe. F7 (LOW-MEDIUM) — AP-213 already named the label format and the click/double-click substitution explicitly on inspection; no row edit needed. F8 (LOW) — AP-212 now names all SIX of `DoRandom`'s decompiled primitives (added the three the original row omitted: `RandomizeAppearance @ 0x005c4f10`, `RandomizeClothing @ 0x005c6770`, `RandomizeCharacter @ 0x005c6d80`, independently verified against the decomp alongside the three already-cited ones) and states the known landing site (Runtime, beside CC3's `CharGenState` ports). F9 (LOW) — AD-101's retirement condition corrected: must happen before CC5's Finish un-ghosts, not merely "at CC6b" (CC5 precedes CC6b in the slice order; shipping Finish first would let a create complete on an implicit gender default). F10 (LOW) — merged `ItemAppraisalTextFormatter.SkillName`'s two consecutive `` blocks into one. F11 (LOW) — TS-82's "see AP-211's sibling gate" cross-reference was wrong (AP-211 is the unrelated roster-slot-cap refusal); corrected to point at TS-82's own CC5 dependency. F12 (LOW) — cached the chargen `DatStringResolver` once per composition (`characterCreationStrings` in `InteractionRetainedUiComposition.CreateRetainedUi`) instead of constructing + DAT-locking fresh on every `ResolveText` call; the `LinesProvider` per-Refresh closure allocation already matched the house pattern used throughout `CharacterStatController.cs` and elsewhere, so it was left as-is. F13 is a merge-mechanics note (TS-82 collides with campaign-cc6a's TS-82/83) for the orchestrator at merge time — no acdream-side action taken. **CC4 re-review round (`ec854db0`'s own fix round, 2026-08-15) — R1 (MEDIUM, blocking, architectural, NEW residual introduced by the F1 fix above):** the F1 fix's raw `_host.FixedCanvasSize = null` in `Close()` was STILL a bug — character-creation can be simultaneously active on top of character-management (which stays active underneath, ticking its own roster), and nulling the shared host-global from either screen without regard for the OTHER screen's own active declaration strips it out from under whichever screen is still open (the exact AD-98 gate-round-2 misalignment defect resurfacing one layer up: char-select renders unstretched with dialogs centered against the raw window). Root cause per the reviewer (agreed): TWO controllers writing ONE host-global with no owner. **Fix — the root-cause shape, no workaround:** `UiRoot` gained a single arbiter, `DeclareFixedCanvas(object owner, Vector2 size)`/`RevokeFixedCanvas(object owner)` (see AD-98's own register row for the mechanism detail); both `CharacterCreationUiController` and `CharacterManagementUiController` now declare on their activation edge and revoke on close/deactivate/dispose instead of writing `FixedCanvasSize` directly — grepped for stragglers, none remain in production code; the raw property setter stays public only for `UiRootFixedCanvasTests`' isolated scale-math coverage. **Test (reviewer-specified):** `tests/AcDream.App.Tests/UI/Layout/CharacterScreensFixedCanvasArbiterTests.cs` — two controllers sharing ONE `UiRoot`, asserting the canvas across the full sequence (char-mgmt active → chargen Open → chargen Exit-confirm Close, canvas STAYS SET because char-mgmt is still active → char-mgmt deactivate, NOW it nulls) plus the original F1 defect's own covering case (both screens revoke together at world entry). **R3 (LOW):** `tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs`'s new `ContentLease_InstallsRealChargenOptions_SelectHeritageIsAccepted` proves F6's install actually opens the gate — a `HeadlessSessionHost` built with a content lease carrying a REAL hand-built `DatCharGen` heritage (not `ChargenOptions.Empty`) has that heritage present in `CharacterCreationState.Options`, and `TrySelectHeritage` for it succeeds once `Begin` is called (both called directly via this project's existing `InternalsVisibleTo` on `AcDream.Runtime`, isolating the F6 wiring from the unrelated real-network handshake needed to reach the same session state through the normal command gate). **R2 (LOW):** filed `docs/ISSUES.md` #402 for the pre-existing `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` full-suite flake (passes isolated, fails ~2/5 full-suite runs, last touched `82f8d4f8` 2026-07-25 — unrelated to Campaign CC) so it stops being re-discovered. **R4 (LOW):** fixed the "unchached" → "uncached" typo in `InteractionRetainedUiComposition.cs`'s F12 comment. Runtime 1713/0 (unchanged), App 5127/13 skips (+2 new: 2 `CharacterScreensFixedCanvasArbiterTests` cases), Headless 166/0 (+1 new: R3's test), full solution Release build green. | +| CC5 | REVIEW-CLOSED 2026-08-16 | `34e3a534`, `a975efd1` (ledger), `0c8e1e7d` (fix round), `2d4168f9` (ledger), residual round `356545c5` | CLOSED (dual-lens: architectural PASS-with-items, retail-fidelity FAIL → F1-F14 fix round `0c8e1e7d` → narrow re-review: all code fixes oracle-verified, residuals R1-R5 all test/doc → this commit; re-reviewer pre-authorized lead diff-check close) | Summary page (`CharacterCreationSummaryPage`, `src/AcDream.App/UI/Layout/`) fills TS-82's placeholder: name field (`0x10000402`, `UiField`) with `NameInputFilter @ 0x004663b0` ported verbatim (ASCII letter/space/apostrophe/hyphen) and the retail commit-on-idMessage-0x12-or-0x44 dispatch (`ListenToElementMessage @ 0x0047bf40`) mapped onto `UiField.OnFocusLost`/`OnSubmit`; a >32-char commit reverts the field and shows `ID_CharGen_NameTooLong` (`DoNameLimitDialog @ 0x0047bd80`) — the field's own `UiField.MaxCharacters` is deliberately left UNCAPPED so this retail code path stays reachable (a per-keystroke cap would make it dead, an F1-class bug caught by `SummaryNameField_TooLong_...` failing before the fix); the 32-vs-decomp's-literal-33 threshold choice is register AP-225. The listbox (`0x10000400`, `UiTemplateListBox`) ports retail's REAL three-row-template system verbatim — NOT a flat simplification like the Skills page's — confirmed against the installed EoR dat via a live probe before writing any page code (`SetSummaryText @ 0x0047b1d0`'s three `AddItemFromTemplateList` indices: template 0 = one `UiText` line at child `0x100002f9`, template 1 = a category-header `UiText` at `0x100000fe`, template 2 = a key/value `UiText` PAIR at `0x100002fc`/`0x100002fd` — all three CONFIRMED present with those exact child types by `CharacterCreationLiveDatTests.SummaryPage_HasNameFieldListboxTemplatesAndViewport`, replacing an earlier scratch Console.WriteLine probe used to derive the finding). Populated rows: Profession/Gender/Heritage/Starting Town (template 0), an "Attributes" header (template 1) + Strength/Endurance/Coordination/Quickness/Focus/Self/Health/Stamina/Mana/Skill Credits (template 2, ten pairs matching `SetSummaryText`'s own 0..9 loop — Health/Stamina/Mana reuse `CharacterCreationProfessionPage.Refresh`'s own already-cited `UpdateAttributeValues @ 0x00482450` formulas rather than this page's OWN decompiler-ambiguous `GetAttribute(2)`/`GetAttribute(2)` pair, register AP-224), then Specialized/Trained skill-name listings only (retail's other two Untrained buckets skipped, same class of cut as AP-213's own precedent, also AP-224). Summary's viewport (`0x10000406`) is its OWN `gmCG3DView` instance — decomp-confirmed a SEPARATE instance from the Appearance page's (`InitializePage @ 0x0047bbf0`'s own `gmCG3DView::gmCG3DView`/`SetCamera`/`SetPlayerHeading(180)`/`StartAnimation` calls, matching the plan's own citation) — wired through a SECOND, independent `ChargenPreviewRenderer`/`ChargenPreviewController` pair (no zoom/rotate buttons bound, matching retail's own control-less Summary viewport) mirroring the Appearance preview's exact one-shot composition shape end to end: `LivePresentationResult`/`LivePresentationComposition.Compose` (a new `RetailSummaryPreviewPageVisibility` sibling class), `FrameRootComposition`'s `PrivateEntityViewportFrameGroup` (4th member), `GameWindow`/`GameWindowLifetime` guard fields + `RenderShutdownRoots` disposal entries, and `RetailUiRuntime`'s `SummaryPreviewViewportWidget`/`SummaryPreviewControl`/`IsSummaryPreviewPageVisible` — the SAME AP-221 one-shot-composition-vs-retryable-coordinator fragility applies to this second binding too (not filed as a separate row; AP-221's own text already generalizes to "every private viewport" this pattern touches). **RandomizeCharacter port (the F12 amendment's own explicit requirement, `RuntimeCharacterCreationState.cs`):** `CharGenState::RandomizeCharacter @ 0x005c6d80` and its six sub-primitives (`RandomizeAppearance @0x005c4f10`, `RandomizeHeadgear @0x005c5e10`, `RandomizeShirt @0x005c5ef0`, `RandomizeTrousers @0x005c5fb0`, `RandomizeFootwear @0x005c6070`, `RandomizeClothing @0x005c6770`, `RandomizeTemplate @0x005c6500`) are ported faithfully, not approximated — the RNG primitives both retail overloads reduce to are independently confirmed from TWO sources: the decompiled bodies of `RandInt(int) @0x00684400` (uniform `[0,count)`) and `RandInt(int,int) @0x00684420` (re-roll until different from the excluded value, short-circuiting to 0 for `count<=1` to avoid an infinite loop), AND `acclient.h`'s own `CharGenStateVtbl` struct, whose `___u1` member is literally a union of `GetRandomInt(this,int,int)`/`GetRandomInt(this,int)` — confirming `RandomizeAppearance`'s vtable-indirected calls are this SAME pair, not a distinct unnamed algorithm (a finding that resolved what would otherwise have been a genuine BN-decompiler ambiguity, per the class of trap `feedback_bn_decomp_field_names.md` warns about). The heritage roll (`RollDice(1, hasToD?4:3)`) is confirmed to pick ONLY among the four HUMAN heritage groups (`ChargenHeritageGroup.Aluvian..Viamontian`, ids 1-4) — a genuine retail quirk (a "random" character is always human) reproduced faithfully, not "fixed" to roll among all 13; the hasToD bound reuses AD-102's own already-established convention (acdream has no account/DLC signal, treats every account as ToD-owning) rather than inventing a second one. `RandomizeTemplate`'s Olthoi branch (`template_=1` then `ApplyTemplate` force-resets to 0 — the intermediate write is a decomp-confirmed no-op, this port skips straight to the force) is real but structurally UNREACHABLE through `RandomizeCharacter` specifically (that caller's own heritage roll never lands on Olthoi) — its own standalone exposure was out of this slice's named scope (only Appearance+Summary consumers were required), so it stays an internal-only helper this round. Three new Runtime command surfaces (`TryRandomizeCharacter`/`TryRandomizeAppearance`/`TryRandomizeClothing`) thread through the full stack (`IRuntimeCharacterCreationCommands` → `LiveSessionController` → `CurrentGameRuntimeAdapter.CharacterCreationProjection` → `DeferredGameRuntimeStateCommands` → `CharacterCreationRuntimeBindings`), consumed by three call sites: (a) `CharacterCreationUiController.Open`'s new `RollOpeningCharacter` — retiring AP-214 outright (deleted, not narrowed): the chargen screen now rolls a full random character before showing Heritage, exactly mirroring `gmCharGenMainUI`'s ctor-time call, and then reproduces `gmCGAppearancePage::InitializePage`'s own gender-read-and-FLIP-to-the-opposite (`~0x004802da-0x00480303`, decomp-confirmed `mGender==1→SetGender(2)`/`mGender==2→SetGender(1)`) — since acdream's pages are constructed once at mount time rather than per-visit like retail's whole UI tree, `Open()` (already the established one-shot-per-visit hook for the fixed-canvas declare) is the closest analogue to "runs once per gmCharGenMainUI construction," so both the roll and the flip land there; (b) the Summary page's Random button, gated behind `MakeRandomizeWarningDialog @ 0x004e8a90`'s `ID_CharGen_RandomizeWarning` confirmation (`gmCharGenMainUI::CloseRandomizeWarningDialog @ 0x004e8400`'s own confirm-arm re-invoke, verified NOT re-entrant into the warning gate since that gate lives in the button-click dispatcher, not inside `DoRandom` itself); (c) the Appearance page's Random button, dispatched on the page's own Face/Clothes sub-tab (`DoRandom @0x004e7d70` case 3) — both (b) and (c) retire the Appearance+Summary halves of AP-212 (narrowed, not deleted — Heritage/Profession/Town's uniform-pick and Skills' hard-disable are unchanged, out of this slice's scope). **Finish flow:** `_finish.OnClick` wired to `OnFinish`/`TryFinish` (previously null — retail enables Finish on Summary only, `ListenToElementMessage`'s own `m_eProgressState != ECG_SUMMARY` no-op guard now reproduced via `ApplyProgressState`'s `_finish.Enabled` gate instead); on a local `NoName` refusal shows `ID_CharGen_NoNameWarning` (plain message dialog); on `AttributeCreditsUnspent` shows `ID_CharGen_CreditWarning` (`MakeCreditWarningDialog @ 0x004e8870`), whose confirm re-invokes `TryFinish(confirmedUnspentCredits: true)` — retail's `DoFinish(this,0)` call at `RecvNotice_CloseDialog @0x004e98bb`, already CC3-built (`TryBeginFinish`'s `confirmedUnspentCredits` parameter existed since the CC3 review-fix round, this slice is its first UI consumer). **F12 amendment — `RuntimeCharacterCreationLocalRefusal.HeritageOrGenderUnset`** (register AP-223): a NEW acdream-only local refusal in `TryBeginFinish`, checked right after the empty-name check — retail's own `DoFinish` has no such check because it can't reach a state where either is unset (the ctor-time roll makes it architectural), so this is a defensive backstop for any caller (headless bot, future direct command) that bypasses the screen-open roll; normally unreachable through the ordinary UI now that (a) above always runs first. **0xF643 rejection dialogs** (`ReconcileDialogs`, dedup'd against the last-shown rejection instance since `Tick`/`ReconcileDialogs` runs every frame, not just on revision change): NameInUse→`ID_Character_Err_NameReserved`, NameBanned→`ID_Character_Err_NameBanned`, Pending/Corrupt/DatabaseDown→`ID_Character_Err_NameDBDown`, AdminPrivilegeDenied→`ID_Character_Err_NameAdminDenied`, Undef/any unrecognized code→`ID_Character_Err_NameDBDown` (default arm) — **corrected at the CC5 review-fix round, F2 (2026-08-16): the original CC5 claim that "Pending/Undef never reach this dialog — CC3's `ApplyCreationResponse` treats them as a silent reset" was WRONG.** Byte-decoded `gmCharGenMainUI::RecvNotice_CharGenVerificationResponse @0x004e9030` shows Pending is an explicit switch case landing on the SAME `NameDBDown` label as Corrupt/DatabaseDown, and Undef falls through the function's `(arg2-1) > 6` unsigned-underflow default arm to that same label — there is no silent branch in retail's dispatch at all. `ApplyCreationResponse` now produces a real `RuntimeCharacterCreationRejection` for Pending/Undef instead of a silent state reset, so ACE's disabled-Olthoi Pending rejection (which used to make Finish a silent no-op forever) now correctly surfaces the NameDBDown dialog; dismiss calls the already-existing `AcknowledgeRejection` command (now finally wired to a UI consumer via a new `SetName`/`AcknowledgeRejection` pair on `CharacterCreationRuntimeBindings`, both of which existed on `IRuntimeCharacterCreationCommands` since CC3 but had no App-layer binding until this slice). **Register bookkeeping this commit:** TS-82 RETIRED (50→49 active TS rows); AP-214 RETIRED (RandomizeCharacter now ported); AP-212 NARROWED (Appearance/Summary closed, Heritage/Profession/Town/Skills remain); AP-223/AP-224/AP-225 filed (158-1+3=160 active AP rows) — the HeritageOrGenderUnset local refusal, the Summary listbox's two-bucket skill-list narrowing (reusing AP-213's precedent), and the 32-vs-33 name-length threshold reconciliation. **Tests:** `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (+11: the two new HeritageOrGenderUnset refusal cases, a 200-seed sweep proving the heritage roll never escapes the four human ids even with an Olthoi/Impoverished heritage present in the fixture, a full-roll appearance/clothing/template/start-area completeness check, an inactive-state rejection case, appearance/clothing standalone-command gating, and a 50-iteration single-option-list hang check pinning `RandInt`'s `count<=1` short-circuit) — the fixture (`RuntimeCharacterCreationStateFixture.cs`) gained heritage ids 2-4 (mirroring Aluvian) and a second (Female) gender option on every human heritage, since a real `RandomizeCharacter` roll now needs both genders resolvable or half of all seeds hit the "gender resolves to nothing" fallback path by design; `tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs` (+23: open-roll/gender-flip pair, five Finish-flow cases, Random-on-Summary confirm/cancel, Random-on-Appearance Face/Clothes dispatch, three name-field cases, two rejection-dialog cases, plus the two CC4-era Finish/Random tests REWRITTEN for the new un-ghosted/enabled behavior — `Finish_GhostedExceptOnSummary`, `Random_IsDisabledOnSkillsPageOnly`); `tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs`'s scratch structure probe replaced by a permanent `SummaryPage_HasNameFieldListboxTemplatesAndViewport` gate. Counts (Release, `ACDREAM_PROBE_LIVE_MOUNT=1` + `ACDREAM_DAT_DIR` set so every installed-DAT-gated test runs): Runtime 1722/0 (was 1713/0), App 5240/3 skips (was 5223/3, two consecutive full-suite runs both clean — one earlier single-run failure in the UNRELATED, pre-existing `SocialPanelLiveMountProbeTests.ProbeLiveMountShapes` passed clean standalone and on the immediate full-suite re-run, a known flake class not touched this slice), Headless 166/0 (unchanged, confirms the `IRuntimeCharacterCreationCommands` interface addition needed no Headless-side changes), full solution Release build green. **OPEN for CC6/CC7:** the dual-lens review itself; Heritage/Profession/Town's Random still uniform-pick (AP-212 residual, not this slice's scope); `RandomizeSkills`/the Skills-page Random stays hard-disabled; the Summary "How To" text (`0x10000404`) is mounted but left unpopulated — no decomp citation for its content was pursued this round (out of the plan's named scope; a minor, harmless gap, not a functional one); the F12-amendment's own note that `RandomizeTemplate`'s Olthoi branch is real-but-structurally-unreachable through the ported call graph is left as an internal observation, not a register row (nothing user-observable diverges from it). **Re-review residual round (2026-08-16, this commit):** the narrow re-review of `0c8e1e7d` found every code fix oracle-verified but returned NOT CLOSED on five test/doc residuals plus nits. R1 — added the missing App-layer regression test (`CharacterCreationUiControllerTests.SummaryNameField_RealCommitAfterExternalRefreshWhileUnfocused_StillReachesSetName`) that actually drives the F1 bug shape (external Refresh-driven `SetText` while unfocused, THEN a real `SetText`+`Submit` user commit), since the claimed coverage never touched the page. R2 — added direct `RetailSkillFormula.CalculateChargenScore`/`ChargenSkillScoreResolver` coverage (`tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs`: a Untrained/Trained/Specialized theory, the divisor-zero skip path, and a six-way `AttributeId` theory), replacing the F12(d) test's `skillId * 10` substitute as the ONLY prior coverage. R3 — MEASURED (not assumed) the installed DAT's SkillTable `MinLevel` distribution (`CharacterCreationLiveDatTests.SkillTable_MinLevelDistribution_NeverExceedsTrained`: 23 skills at MinLevel 1, 15 at MinLevel 2, of 38 priced skills, zero above 2) and restated `RetailSkillFormula.cs`'s doc comment around the measured fact instead of the unverified "no skill exceeds Untrained=1" claim — ACE's own hedge (`// 1-2?`) was right; the structural "gate holds for any MinLevel in {1,2}" argument is now the load-bearing one, not the data claim. R4 — filed AP-228 (the Summary/Skills skill-row KEY sourcing from `ItemAppraisalTextFormatter.SkillName`'s hardcoded English switch, where retail's own key is DAT-sourced — same class as AP-226, reversed polarity, also present at CC4's Skills page) and softened AP-224's "ported exactly" claim to note it only ever covered the row's VALUE/template, never its KEY. R5 — this commit's message corrects `0c8e1e7d`'s false "Release build zero warnings" gate claim (18 pre-existing warnings, all in the unrelated `AcDream.Core.Tests` project, none in any project this campaign touched). Plus three nits: the `ChargenPreviewController` ctor doc now also cites `gmCGSummaryPage::Update @0x0047baa0` (the per-heritage re-derive site, not just the one-shot `InitializePage` seed); the F2 inline comment's "Finish becoming a permanent no-op" reworded (`_verificationPending` was already cleared pre-fix too — Finish was never blocked, only the RESPONSE feedback vanished); and #404 filed for `ChargenSkillScoreResolver`'s own independent SkillTable read alongside `ChargenTableReader`'s (cleanup, not urgent — not this round's scope). | +| CC6a | CODE-COMPLETE 2026-08-15 (foundation only — narrowed scope per the CC4∥CC6a parallelism contract: no page mount, no spin/color-wheel controls, no rotate/zoom behavior; all deferred to CC6b after CC4 merges) | `55bfd9ca` (foundation), `1774d8b2` (same-session review fix round, F1-F12) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1/F2/F3 — all three (plus F4-F10) landed this round; F11/F12 are CC6b-scope notes only (see below) | **Index→ObjDesc factory** (`ChargenAppearanceFactory.TryCompose`, `src/AcDream.Core/CharGen/`, pure — no Chorizite types on its public surface, verified by the existing `ChargenNoChoriziteLeakTests` reflection guard, which walks the whole `AcDream.Core.CharGen` namespace and now covers these new types too): ports `gmCG3DView::Update @ 0x004EE9D0`'s ObjDesc rebuild in its EXACT decompiled append order — base body → hair style → **Headgear → Trousers → Shirt → Footwear** (verified from the decompiled control flow, NOT the UI tab order 5/6/7/8 or the CC2 wire's field order, both of which are headgear/shirt/trousers/footwear and would have been wrong) → eyes (bald-aware) → nose → mouth → skin subpalette (UNCONDITIONAL, no selection gate, unlike every other slot) → hair color → eye color. New pure Core types: `ChargenPalSet`/`ChargenPalSetMath` (shade→index), `ChargenClothingTable`/`ChargenClothingBaseEffect`/`ChargenClothingPaletteTemplate`/`ChargenClothingSubPaletteChoice` (pure ClothingTable projection), `IChargenPalSetSource`/`IChargenClothingTableSource` (DAT-touching work pushed behind these, implemented by the new Content-layer `ChargenAppearanceCatalog`, `src/AcDream.Content/CharGen/`, a cached dat reader mirroring `ChargenTableReader`'s discipline), `ChargenAppearanceSelection` (mirrors `RuntimeCharacterCreationAppearance`'s 14-index/6-shade shape field-for-field so CC6b's Runtime→Core mapping is a trivial copy — kept as a separate type since Core cannot depend on Runtime). **Palette resolution — two sources, no guessing (corrected at the review fix round — see F3 below):** `PalSet::GetPaletteID`'s FPU-elided body (`(int)((count - 0.000001) * shade)`, clamped) is corroborated by ACE's `PaletteSet.GetPaletteID` (comment: "Taken from acclient.c") AND the decomp's own control-flow shape (the `>= 0.0` gate at `0x005AC5A0`). ACViewer's `ClothingTableList.xaml.cs:97` does NOT corroborate this — it computes a different expression (`Shades.Maximum - 0.000001`, i.e. `count-1`, not `count`) for a different problem (mapping a shade back to a UI slider position), and `references/ACViewer`'s vendored `PaletteSet.cs` is ACE's own file, not an independent reimplementation — the original "three independent sources" claim overcounted by one. Skin/hair use `PalSet`+shade indirection (skin: `sex.SkinPalSet`; hair: `sex.HairColors[i]` is ITSELF a PalSet id — confirmed against `PlayerFactory.cs:96`); eye color is the ONE exception — a raw Palette id used directly with NO shade indirection (confirmed against `PlayerFactory.cs:100`'s `EyesPalette = sex.EyeColorList[eyeColor]`, no `GetPaletteID` call, unlike the two lines above it). Hard-coded overlay ranges recovered from the decomp's literal bytes: skin (real offset 0, count 192 → packed 0/24), hair (192/64 → packed 24/8), eyes (256/64 → packed 32/8) — all three independently cross-checked against `PaletteOverride`'s pre-existing `*8` packing doc comment. **Clothing dye resolution, installed-DAT-verified:** `CharGenState::GetHeadgearPaletteTemplateID`/Shirt/Trousers/Footwear (0x005C38F0-0x005C3980) each read a PER-SLOT cached array, but all four are populated from the SAME single `Sex_CG::ClothingColors` dat field — there is no per-slot color list in the schema at all. This CONFIRMS (not merely approximates, contra the original AP-208 framing) that CC3's shared-list design is exactly retail's own mechanism; live-DAT probe: Aluvian male `ClothingColors = {9,6,4,8,7,5,2,3,13}` and the "Cloth Cap" headgear's `ClothingSubPalEffects` keys include every one of those values directly. **Chargen preview renderer** (`ChargenPreviewRenderer`, `ChargenPreviewCamera`/`ChargenPreviewViewportCamera`, `ChargenPreviewEntityBuilder`, all new files under `src/AcDream.App/Rendering/`): follows `PrivateEntityViewportRenderer`'s exact architecture (offscreen target → texture table → `UiViewport` sprite later), a THIRD facade beside `PaperdollViewportRenderer`/`CreatureAppraisalViewportRenderer` — no existing file touched. `ChargenPreviewEntityBuilder.TryBuild` resolves Setup/GfxObj/Surface/Animation dat data itself (there is no live entity yet) using the SAME algorithms as `DatLiveEntityProjectionMaterializer` (surface-override resolution ported verbatim) and `RetailPaperdollPoseApplicator` (final-frame held pose), generalized to the per-heritage rest-pose DID retail actually uses (`m_didAnimationRest`: enum `0x10000005` for every standard heritage — the SAME id the paperdoll's own pose reads — `0x10000011` for Olthoi, `0x10000013` for OlthoiAcid, all resolved through master-map slot 7). **Camera** (`gmCGAppearancePage::Update @ 0x0047E8F0`, cross-checked against the identical literals in `ZoomIn`/`ZoomOut @ 0x0047CF00`/`0x0047D050`): four distinct default (zoomed-in) eye profiles across the 13 heritages — Olthoi (0,-1.85,1.85), OlthoiAcid (0,-3.05,2.75), Tumerok (0,-0.85,1.65), everyone else including Gearknight (0,-0.55,1.65) — direction always identity (zero yaw/pitch, same convention `DollCamera` already established); zoomed-OUT profiles also recorded for CC6b (Olthoi (0,-3.80,1.15), OlthoiAcid (0,-5.70,1.65), everyone else (0,-2.50,0.95) — no Tumerok special case on the OUT side). Rotation is NOT a camera property: retail's continuous-rotation button spins the CHARACTER (`CPhysicsObj::set_heading`), not the camera — CC6b's heading parameter belongs on the entity builder. **Constants recovered, not just cited (deliverable #4):** `RotationSecondsPerRevolution = 3.0` (clean in the decomp, no reconstruction needed) and `ZoomTweenDurationSeconds = 0.6` — the plan's own risk list flagged this SECOND constant as "decompiler-garbled"; it is NOT unrecoverable: reinterpreting the decompiler's garbled float literal as the raw low-32-bit store and pairing it with the (clean) high dword reconstructs the exact IEEE-754 double both at `DoZoomAnimation`'s reset-default site (→ 0.6) AND independently at `ZoomIn`/`ZoomOut`'s `-0.1` invalidation sentinel (→ exactly the textbook IEEE-754 bit pattern for -0.1, cross-confirming the reconstruction technique itself). **Register rows filed (same commit):** TS-83 (the CC6a static-pose-vs-retail-idle-loop staging, explicitly named by the plan, to be retired by CC6b) and TS-84 (a MEASURED, not assumed, scope cut — CC6a's composer does not port retail's ~8-branch clothing Setup-substitution chain; the installed-DAT catalog test proves this costs nothing for the 9 standard heritages whose UI shows clothing controls, but Undead's default gear choices genuinely miss `ClothingBaseEffects` coverage on ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear, not the three-slot "headgear/trousers/footwear" an earlier draft of the row understated — for Undead's own live body Setup on both genders; the review fix round pinned this exact 4-table-id measurement with a real assertion rather than a WriteLine (F7), and corrected the row/doc-comment undercount (F2) — a real, narrow, documented gap, not a "confirmed unreachable" overclaim). **Tests (final, post-fix-round counts):** `ChargenPalSetMathTests` (10 cases, the shade-index formula), `ChargenAppearanceFactoryTests` (24 hand-built-fixture cases — the original 19 plus F1's 2 INVALID_DID-sentinel cases, F8's 1 abort-on-PalSet-miss case, F10's 2 packed-byte-conversion cases — covering setup resolution, retail append order, bald-strip selection, unconditional skin, missing-dat diagnostics, out-of-range indices), `ChargenAppearanceCatalogInstalledDatTests` (2 methods: the original installed-DAT sweep — all 26 heritage/gender combinations, zero missing PalSet/ClothingTable ids, PLUS F7's pinned TS-84 assertions — and F1's new 869-selection hair-style Setup-resolution sweep — PASSED live against the installed EoR dat), `ChargenPreviewCameraTests` (17 cases, every per-heritage literal + the two recovered constants), `ChargenPreviewEntityBuilderTests` (3 cases, installed-DAT-gated, proves a real Aluvian-male 34-part mesh + Olthoi's distinct pose DID both resolve without touching a live entity, now exercising the F4 `datLock` parameter). + +**Review fix round (F1-F12, same session):** F1 (BLOCKING) — `hairStyle.AlternateSetup != 0` / `setupId == 0` tested the wrong sentinel; retail's Setup "unset" is `INVALID_DID` (0xFFFFFFFF — `CharGenState::GetSetupID @0x005C5B22`), not 0, so an `AlternateSetup` field storing that value would have been ADOPTED as a literal Setup id, nulling `Get` and killing the whole preview. Fixed at both sites (`ChargenAppearanceFactory.cs`, new `InvalidDid` constant); two new hand-built tests plus a new installed-DAT sweep (`EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId`, 869 selections across all 26 heritage/gender combinations, zero unresolved). F2 (BLOCKING) — TS-84's register row, `ChargenClothingTable.cs`'s doc comment, and this ledger row all understated Undead's measured gap as "headgear/trousers/footwear" (3 slots) with a self-contradicting "4 of 4 non-shirt slots" aside; corrected everywhere to the true measured ALL FOUR slots (headgear, trousers, shirt, footwear). F3 (BLOCKING) — the "three independent sources" palette-math claim overcounted; corrected to the two that actually hold (decomp control flow + ACE's cited port) in `ChargenPalSetMath.cs`'s doc and this row (see above). F4 (MEDIUM, landed despite no CC6a call site yet) — `ChargenPreviewEntityBuilder.TryBuild` did unlocked dat reads; `DatCollection` is not thread-safe and every sibling dat-touching resolver in this layer takes a shared `object datLock`. Added a required `datLock` parameter; every dat read (Setup fetch, held-pose resolution, per-part GfxObj checks, surface-override resolution) now happens inside one `lock`, mirroring `RetailPaperdollPoseApplicator.Apply`'s "resolve under lock, process after" shape. F5 (LOW) — `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` is a PRE-EXISTING timing flake unrelated to any chargen code (passes 15/15 in isolation per the reviewer); noted here so a future session doesn't chase it as a CC6a regression. F6 (LOW) — `ChargenPreviewCamera.cs`'s rotation doc cited a nonexistent `RotationDegreesPerSecond` identifier in a dimensionally-wrong expression; corrected to retail's actual per-tick formula (`DoRotation @0x0047CAC7`: `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`). F7 (LOW-MEDIUM) — the installed-DAT tests' env-gated skip returns green with a console note when no dat dir is configured (confirmed this IS the house pattern — no Content installed-DAT test in the project uses `Assert.Skip`, so it was kept rather than diverging), but the TS-84 measurement was WriteLine-only; now pinned with real assertions (zero gaps for the 9 standard heritages, exactly the 4 measured Undead table ids on both genders — `[0x10000009, 0x100000F9, 0x10000001, 0x10000007]`, same order both genders). F8 (LOW) — the inner PalSet-miss loop recorded-and-continued past a miss; retail's own loop (`ClothingTable::BuildObjDesc` ~0x005A7B24-0x005A7BD3) returns 0 immediately on a miss at ~0x005A7B32, ABORTING every remaining choice in that garment — `continue` changed to `break`, new test proves a second (present) PalSet's choice is correctly NOT applied when it follows a missing one. F9 (LOW) — three dangling `` doc-comment references (the method is `TryCompose`) fixed. F10 (LOW) — the packed `(byte)(range.Offset/8)`/`(byte)(range.NumColors/8)` narrowing on dat-sourced data was unchecked (a real `NumColors` of 2048 wraps 256→0 as an unchecked byte cast, which HAPPENS to match retail's own "0 means whole palette" sentinel); replaced with explicit `PackOffset`/`PackNumColors` helpers that document the 2048→0 equivalence deliberately and throw `ArgumentOutOfRangeException` on any other unrepresentable shape, with two new tests (the sentinel case, the throwing case). F11/F12 (LOW, CC6b scope, no code this round) — noted in the CC6b row below: the second `m_alternateSetupID` override source (the appearance-page option checkbox — Penumbraen crown `@0x004DFB3F`, Undead no-flame `@0x004E0C54`, precedence at `@0x004EEA51`) is unmodelled; a shared `RetailHeldPose` helper is worth extracting before a fourth held-pose consumer exists (paperdoll, appraisal's live-target case is different, chargen — a third, not yet fourth). **F11 CONCEDED MIS-SCOPED at the CC6b-PRE review fix round (2026-08-15):** the two cited write sites are `gmBarberUI`'s, not `gmCGAppearancePage`'s — see the CC6b-PRE row's own corrected item 4 for the citation table (enclosing-function scan) and the resulting directive that CC6b-mount must NOT build an option checkbox here. **Test counts after the fix round (measured, not projected):** Core.Tests 4772/1 skip (+5 from F1's two hand-built tests, F8's one, F10's two), Content.Tests 147/0 skips (+1 from F1's new installed-DAT sweep — F7 added assertions to the EXISTING installed-DAT test rather than a new one), App.Tests 5121/6 skips (unchanged pass count; F5's named flake did NOT reproduce in this session's full-suite run) — zero failures, full solution Release build green. | +| CC6b-PRE | PRE-MOUNT HALF CODE-COMPLETE 2026-08-15 (the mount-independent scope only — idle animation, rotation, zoom for the chargen preview; the page-mount half — Appearance page, spin controls, color wheels, viewport wiring — is a SEPARATE follow-up landing after CC4 merges, per the original CC6 split) | `8dfee111` (pre-mount half), plus a same-round review fix commit (F1-F7 + the F11-concession rewrite) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1 — landed this round along with F2-F7 and the ALSO item (the reviewer's claim-2 barber refutation was UPHELD; claim-1's idle-by-default CONCLUSION was correct but its "elided ctor byte" argument was unsound, replaced with the real `InitializePage` evidence) | **Idle animation loop, TS-83 RETIRED:** decomp re-read of `gmCGAppearancePage::Update`'s own trailing gate (~0x0047EF01-0x0047EF12: `if (m_bZoomedIn == 0) StartAnimation(); else StopAnimation();`, unconditional on every Update call — heritage/gender change or page becoming visible) plus the DIRECT ASSIGNMENT evidence located at the re-review — `gmCGAppearancePage::InitializePage @0x0047FDD0` writes an explicit `m_bZoomedIn = 0` at `0x004802C3`, right after setting the camera to the zoomed-IN per-heritage eye at `0x00480286-0x0048029E` (the null-tween quirk); the earlier elided-ctor-byte argument was UNSOUND (heap-new members are indeterminate, not zero) and is superseded — settles a fact CC6a's own TS-83 row left as "not yet located precisely": **retail's chargen preview defaults to the idle loop PLAYING, not the frozen rest pose** — the rest pose only appears once the user presses Zoom In, which retail's own `ZoomIn`/`ZoomOut` (`0x0047CF00`/`0x0047D050`) call `gmCG3DView::StopAnimation`/`StartAnimation` for IMMEDIATELY (before the camera's own 0.6s tween even starts). New Core primitive `RetailAnimationCyclePlayback` (`src/AcDream.Core/Physics/`, pure, unit-tested) ports `CPhysicsObj::set_sequence_animation @ 0x0050F6F0`'s effect (advance-with-wrap + lerp/slerp) — the SAME algorithm this codebase's App layer already carries inline for its no-`AnimationSequencer` NPC idle path (`LiveEntityAnimationPresenter.Present`'s legacy branch); the two call sites are NOT consolidated this round (that file is live, heavily-tested, in-flight production entity-rendering code unrelated to this preview-only feature — a deliberate blast-radius call, not an oversight, noted in the new type's own doc comment for a future mechanical pass). New App type `ChargenPreviewAnimator` (`src/AcDream.App/Rendering/`) owns the per-tick idle-frame advance / rest-pose freeze swap; `ChargenPreviewEntityBuilder` gained `TryBuildAnimated` (returns a `ChargenPreviewAnimatedBuild`: the entity, resolved drawable parts, precomputed rest pose, resolved idle Animation + frame range) alongside the ORIGINAL `TryBuild` (kept RESULT-identical, not byte-identical internally — F6: it now also resolves the idle DID and loads the idle Animation before discarding them; a thin wrapper now, all 3 of its existing tests still pass unchanged) — `ResolveIdleAnimEnum` resolves `m_didAnimation`'s enum key (0x10000006 standard, 0x10000011 Olthoi, 0x10000013 OlthoiAcid) alongside the existing `ResolveRestPoseEnum` (0x10000005/0x10000011/0x10000013) — **Olthoi and OlthoiAcid use the SAME enum key for BOTH idle and rest** (retail quirk, decomp-confirmed at ~0x004ee7e9/0x004ee7ff and ~0x004ee892/0x004ee8a8: those two heritages show no visible difference between "playing" and "zoomed in and frozen"). **Rotation controller:** new `ChargenPreviewRotationController` (`src/AcDream.App/Rendering/`) ports `gmCGAppearancePage::Rotate`/`DoRotation` (`0x0047CB50`/`0x0047CA80`) verbatim — toggle-to-stop-same-direction, `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`, a SINGLE-PASS ±360 clamp (not a full modulo — retail's own tail only corrects once, reproduced as-is rather than "improved"), the `-1.0` sentinel `Rotate()` writes to invalidate `m_dLastRotateTime` (bit-confirmed: high dword `0xbff00000` + zero low dword). `ECG_ROTATE_CLOCKWISE=1`/`ECG_ROTATE_COUNTERCLOCKWISE=2` confirmed from `acclient.h:6848-6852` — CLOCKWISE adds to heading, everything else subtracts. Applies to the ENTITY's heading via `MoveToMath.SetHeading` (the exact existing `CPhysicsObj::set_heading` port, reused rather than reinvented), not the camera — confirming CC6a's own architecture note. **Zoom tween:** new `ChargenPreviewZoomController` ports `ZoomIn`/`ZoomOut`/`DoZoomAnimation` (`0x0047CF00`/`0x0047D050`/`0x0047C960`) — a LINEAR (not eased — the decomp shows a straight `(targ-start)*t+start` per axis with no easing curve anywhere in the function) 0.6s tween between `ChargenPreviewCamera`'s already-recorded default/zoomed-out eye profiles, using the same `-0.1` invalidation-sentinel idiom as rotation; `ZoomIn`/`ZoomOut` call into `ChargenPreviewAnimator.SetZoomedIn` IMMEDIATELY (synchronously, inside the button-press method itself — not gated on the tween's own completion), matching the decomp's call ORDER exactly. **Fix round F2:** the controller and the animator originally kept two INDEPENDENT `IsZoomedIn` bools synced only through a nullable animator argument on `ZoomIn`/`ZoomOut` — a null pass, or a direct `ChargenPreviewAnimator.SetZoomedIn` call bypassing the controller, could desync the camera target from the animation pose. Retail's `m_bZoomedIn` is a SINGLE field gating both, so `ChargenPreviewZoomController` now takes its `ChargenPreviewAnimator` as a required constructor dependency and `IsZoomedIn` reads straight through to the animator's own flag — one owner, matching retail's own shape, with no second bool left to disagree. **`m_alternateSetupID` (MUST-COVER item 1) — RESEARCH CORRECTION, not a straight port:** re-reading the decomp function-by-function (not just address-by-address) found that ALL FIVE `m_alternateSetupID` write sites — including the two the CC6a review fix round cited, Penumbraen crown `@0x004DFB3F` and Undead no-flame `@0x004E0C54` — belong to `gmBarberUI`, not `gmCGAppearancePage`. Enclosing-function table (every write site, confirmed by scanning each site's containing function body for sibling calls that only make sense in one class): `@0x004DFB5B` sits inside `gmBarberUI::ListenToElementMessage` (sibling evidence: `gmBarberUI::SetSelection`/`gmBarberUI::Rotate` calls in the same body, which ends in a `CM_Character::Event_FinishBarber` wire call — a barber-shop-only message); `@0x004E0C54` (Penumbraen crown), `@0x004E0D42`, and `@0x004E0DB1` all sit inside the SAME `gmBarberUI::InitializePage` (sibling evidence: `m_pOption1Checkbox` reads and `UIElement_Text::SetStringInfoWithFont` calls on barber-specific string ids in that body); the ONLY thing `gmCGAppearancePage` itself ever does with the field is READ it generically through the shared `gmCG3DView` ctor/`::Update` (every `gmCG3DView` owner does this) — `gmCGAppearancePage`'s own field list (`acclient.h:56373-56428`, checked exhaustively) has NO `m_pOption1Checkbox`-equivalent member and none of its own methods write `m_alternateSetupID`. `gmBarberUI` is the POST-CREATION barber-shop appearance-editing screen — a wholly separate UI class from character creation's `gmCGAppearancePage`. **For character creation, `m_alternateSetupID` is therefore ALWAYS `INVALID_DID` in retail — the barber shop's crown/flame variant checkbox is not reachable during chargen at all**, and is out of this campaign's scope entirely. **Directive for CC6b-mount: do NOT build an option checkbox for Penumbraen-crown/Undead-no-flame variants on the Appearance page — retail has no such control there.** `ChargenAppearanceFactory.TryCompose` still gained a real, decomp-cited `alternateSetupIdOverride` parameter (default `InvalidDid`, i.e. no-op for every existing caller) implementing `gmCG3DView::Update`'s own generic precedence exactly (`~0x004EEA46-0x004EEA53`: the override, when present, REPLACES the hairstyle/gender-resolved setup outright, not additively) — a real mechanism reserved for a hypothetical future non-chargen (barber-shop) consumer of this same factory, not a fabricated chargen feature; 5 new hand-built tests prove the precedence chain and the `INVALID_DID` sentinel discipline. **RetailHeldPose extraction (MUST-COVER item 2) — DONE, clean mechanical extraction:** new `src/AcDream.App/Rendering/RetailHeldPose.cs` shares `ResolvePoseDid` (master-map-slot-7 DID lookup) and `ComposePartTransform` (`Scale*Rotate*Translate`) between `RetailPaperdollPoseApplicator.Apply` (paperdoll, refactored to call the shared helper, behavior byte-identical) and `ChargenPreviewEntityBuilder` (both the pre-existing rest-pose path and the new idle-frame path) — the two sites' surrounding per-index LOOP shapes stayed separate (paperdoll walks an already-filtered `WorldEntity.MeshRefs`; chargen walks the pre-filter Setup-part-indexed scratch list), matching the MUST-COVER's own "only if it stays clean" bar. **Bookkeeping:** TS-83 retired in `docs/architecture/retail-divergence-register.md` (§4 count 50→49, row removed, RETIRED clause added to the header narrative); the CC6a ledger row above now cites its real commit SHAs (`55bfd9ca`, `1774d8b2`) instead of "HEAD of `campaign-cc6a`". **Tests:** `RetailAnimationCyclePlaybackTests` (10, Core), `ChargenAppearanceFactoryTests` (+4, the override precedence/sentinel), `ChargenPreviewRotationControllerTests` (10, +1 this fix round — F7's clockwise-past-360 clamp case), `ChargenPreviewZoomControllerTests` (9, +2 this fix round — F2's null-ctor-throws and read-through-no-independent-state cases; every pre-existing case rewritten for the now-required-animator constructor), `ChargenPreviewAnimatorTests` (7, hand-built fixtures — no dat needed since a `ChargenPreviewAnimatedBuild` is constructible entirely in memory), `ChargenPreviewEntityBuilderTests` (+5, installed-DAT-gated — `TryBuildAnimated` resolves a real idle cycle for Aluvian AND Olthoi, the unknown-setup null path, both Olthoi/OlthoiAcid shared enum keys resolve to a real installed DID). Counts: Core.Tests 4786/1 skip (unchanged this fix round — F1-F7 were doc/API-shape/allocation fixes, no new Core tests), Content.Tests 147/0 skips (unchanged), App.Tests 5152/6 skips (+3 from 5149/6, the F2/F7 additions) — zero failures, full solution Release build green. Two PRE-EXISTING flakes noted across repeated full-solution runs, neither caused by this round and neither reproducing in isolation: `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` (randomized-loss-injection timing, zero files under `src/AcDream.Core.Net/` touched) and `AcDream.Content.Tests.DecodedTextureCacheTests.GetOrCreate_ConcurrentMissRunsFactoryOnce` (a concurrency race under full-solution parallel load, zero files under `src/AcDream.Content/` touched this round either) — both pass 100% run standalone; both projects' full suites otherwise pass clean. **OWED (CC6b page-mount half, separate follow-up):** the Appearance/Summary viewport mount (`0x100003bb`/`0x10000406`), binding the Zoom In/Out and Rotate Clockwise/Counter-Clockwise buttons to `ChargenPreviewZoomController.ZoomIn`/`ZoomOut` (now parameterless — F2 made the animator a required constructor dependency, not a per-call argument) and `ChargenPreviewRotationController.Toggle`/`Tick`, spin controls, color wheels, and the INITIAL HEADING: `gmCGAppearancePage::InitializePage @0x0047FDD0` sets `m_fCurHeading = 180f` at `0x00480235` and pushes it via `SetPlayerHeading` at `0x0048023F` (overriding the ctor’s 0°; cross-confirmed at `gmBarberUI::PostInit @0x004DE330` and the summary page’s `0x0047BD54`) — the mount half must seed `ChargenPreviewRotationController.HeadingDegrees = 180f` or the character faces AWAY from the camera at the user gate. **Explicitly NOT owed:** an option checkbox for Penumbraen-crown/Undead-no-flame variants — see item 4's enclosing-function table above; `gmCGAppearancePage` never had one, so CC6b-mount must not invent one. | +| CC7 | REVIEW-CLOSED 2026-08-16 | `9cf6c522`, `ddcbf1fb`, F1-F9 review-fix round `2176ba76` | CLOSED (dual-lens: both lenses PASS-with-items → F1-F9 fix round this commit; lead diff-check close per the doc/test-only residual pattern) | **Create button un-ghosts** (`CharacterManagementUiController.cs`): retail's exact enable/ghost gate — `gmCharacterManagementUI::UpdateButtons @ 0x004ec240` (~0x004ec319-0x004ec32e, `_charSet.set_.m_num < _charSet.numAllowedCharacters_`, unconditional on selection, unlike Enter/Delete/Restore above it) — is now a real Runtime-owned field, `RuntimeCharacterSelectionButtons.CanCreate`, computed in `RuntimeCharacterSelectionState.BuildButtons` from `_entries.Length < _slotCount` and threaded through every one of that method's return branches (including the delete-in-flight `.None`-shaped ones, which retail's own gate does not couple to). The button's `OnClick` (new `RequestCreate` private method) is wired ONCE in the constructor and calls `_bindings.RequestCreate?.Invoke()`; a new optional `Action? RequestCreate` field on `CharacterSelectionRuntimeBindings` carries the seam. **Cross-controller wiring lives inside `RetailUiRuntime.ConfigureCharacterManagement`** (`src/AcDream.App/UI/RetailUiRuntime.cs`) rather than in the externally-composed bindings record: `RetailUiRuntime` is the one object holding BOTH `CharacterManagementController` and `CharacterCreationController`, so it supplies `bindings with { RequestCreate = () => CharacterCreationController?.Open() }` — a lazily-resolved lambda closing over `this`, safe even though `ConfigureCharacterCreation()` (which populates the creation controller) runs immediately AFTER, not before, `ConfigureCharacterManagement()` in `RetailUiRuntime`'s own mount sequence. `CharacterCreationUiController.Open()` is the SAME entry point the CC4-era `ACDREAM_OPEN_CHARGEN=1` dev seam already called — one code path, two ways to reach it (the seam itself is untouched and remains available for a create-only dev loop). **The chargen-exit return path needed no new code**: character-management is never hidden while chargen is open on top of it (both controllers tick independently, per CC4's own FixedCanvas-arbiter work), so chargen's `Close()` — hiding only its own root — is sufficient; this was PROVEN, not just claimed, by a new cross-controller test (`CharacterScreensFixedCanvasArbiterTests.CreateButtonClick_OpensChargen_AndExitConfirmReturnsToManagement`) that drives the full click→open→exit-confirm→close round trip, asserting management's root stays `Visible` throughout. **Corrected at the CC7 review-fix round, F7 (2026-08-16): the original fixture-ordering claim above was WRONG.** The shared fixture originally constructed chargen FIRST so its `Open` method existed to wire into management's `RequestCreate` binding — the OPPOSITE of production's real tick order (`RetailUiRuntime.Tick`: `_characterManagementMount?.Tick(); CharacterManagementController?.Tick(); _characterCreationMount?.Tick(); CharacterCreationController?.Tick();` — management always ticks first). The fixture now constructs management first, handing it a lazily-resolved closure over chargen's not-yet-existing `Controller.Open` — the SAME trick production's own `RetailUiRuntime.ConfigureCharacterManagement` uses (`bindings with { RequestCreate = () => CharacterCreationController?.Open() }`) — matching production's real construction AND tick order instead of contradicting it. The test also now asserts `Chargen.Controller.Root.ClickThrough == false` and a strictly higher `ZOrder` than management's root once both controllers have ticked with chargen open, pinning the `BringToFront` occlusion effect the reviewer had previously verified only by manual inspection. A second new test (`CharacterManagementUiControllerTests.CreateButton_GhostsWhenRosterReachesTheSlotCeiling_AndUnGhostsBelowIt`) proves the retail gate itself: a 5-character roster against the fixture's `SlotCount=5` ghosts Create, dropping to 4 characters un-ghosts it on the next Tick. **Full-flow tests vs ACE shapes** (`tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs`, extending CC3's existing harness rather than duplicating it — same `TestTransport`/`TestOperations`/`TestHost`/`BuildResponsePacket`/`InvokeProcessDatagram` fixtures, zero new helper classes beyond a decode record): `Finish_SendsEveryWireFieldByteExactAgainstACEsUnpackShape` builds a character touching EVERY 0xF656 field (heritage/gender/all fourteen appearance style-color slots/all six shades/template/an EXPLICIT `TrainSkill` beyond what the template alone applies/an explicit `SelectStartArea`/name), decodes the full body via a new `DecodeCreateRequestFull` (reusing `CharacterCreate.Request`/`Appearance`/`Attributes` directly rather than a second hand-rolled shape) and asserts every field including the trailing checksum. **Corrected at the CC7 review-fix round, F6 (2026-08-16): the checksum half of that claim overstated what the assertion proves.** `Assert.Equal(CharacterCreate.ComputeChecksum(r), decoded.Checksum)` (`LiveSessionControllerCharacterCreationTests.cs:537`) is a round-trip/purity check — it computes the SAME production `CharacterCreate.ComputeChecksum` on both the encode and the decode side, not an independent golden value. It still closes the one gap (`Finish_SendsExactly55SkillSlotsAndTheCorrectAttributesAndName`'s pre-existing test never touched: ~15 non-checksum fields were previously unverified); the checksum's actual golden value lives separately at `CharacterCreateTests.ComputeChecksum_ExactRetailAccumulationSet` (the 19-term sum, golden `205u`), now cross-referenced from this test's own doc comment. `Finish_ThenEachOtherRejectionCode_ProducesTheMappedFailureWithNoRosterOrEnterSideEffect` (`[Theory]`, 6 cases: Pending/NameBanned/Corrupt/DatabaseDown/AdminPrivilegeDenied/Undef — NameInUse excluded, already covered by the pre-existing dedicated Fact) proves CC5's F2 fix (Pending/Undef produce a real rejection, not a silent reset) holds over the REAL wire byte-decode path, not just the isolated `RuntimeCharacterCreationStateTests.ApplyCreationResponse_EachRejectionCode_...` state-machine Theory that already covered all 7 codes at the `ApplyCreationResponse` level directly. **Launcher payload cycle** (item 3): `TestHost` gained an optional `SessionStatusWriter? Writer` + `SessionId`, forwarded from `ApplyCharacterCreated`/`ApplyCreationFailed` EXACTLY the way `LiveSessionRuntimeFactory.Create` (App) and `HeadlessSessionHost` wire it in production (verified by reading both call sites, not assumed) — two new tests (`Finish_ThenOkResponse_WritesCharacterCreatedEvent_ParsedByTheRealLauncherTailer`, its NameInUse sibling) drive a REAL Runtime create/reject through a REAL `SessionStatusWriter` writing to a real temp file, then read it back with the REAL Launcher.Core `StatusFileTailer`/`StatusEventParser` (added as a test-only `AcDream.Runtime.Tests` project reference — `AcDream.Runtime` itself gained no new dependency), asserting the parsed `CharacterCreatedStatusEvent`/`CreationFailedStatusEvent` match §LA1's pinned contract fields exactly. **No gap was found**: `GameWindow`'s constructor already builds a real, non-disabled `SessionStatusWriter(options.StatusFilePath)` and `SessionPlayerComposition.cs` already threads it into `LiveSessionRuntimeFactory`'s constructor alongside the session id — the writer was ALREADY correctly wired on the graphical App host's real create path before this slice; CC7's tests close the missing cross-project VERIFICATION (Runtime's own state transition through the writer's bytes to the tailer's parser), not a functional hole. **Pre-existing test breakage found and fixed** (loudly, per the task's own instruction): adding `CanCreate` to the `RuntimeCharacterSelectionButtons` record broke 4 UNRELATED tests in `LiveSessionControllerTests.cs` (`RestoreCompletionDuringConfirmedDelete_PreservesDeleteUntilAck` ×2, `RestoreTimeoutDuringConfirmedDelete_PreservesDeleteUntilAck` ×2) whose hand-built expected values used `RuntimeCharacterSelectionButtons.None` — a real regression the App-layer and Runtime.Tests standalone runs would not have caught in isolation (each project's own suite is green independently; only the combined change surfaced it). Fixed by threading `with { CanCreate = true }` into all 5 affected `Assert.Equal` expectations (that fixture's roster of 2 sits below its `SlotCount` of 11 throughout), with an inline comment explaining CanCreate's independence from the delete-in-flight buttons those tests actually pin. **Register bookkeeping this commit:** AP-211 (filed at CC3, explicitly predicted "if CC4 later adds the ghosted Create button... revisit whether to keep both or retire this one") updated, not retired — both `TryBeginFinish`'s `RosterFull` local refusal AND the new Create-button gate are intentionally kept as retail-matching enforcement (the button) plus defense-in-depth (Finish's own refusal, for any caller that bypasses the UI). **Connected checklist doc** (`docs/research/2026-08-16-campaign-cc-test-script.md`, following the FA/OP pattern): §CC1 reaching the screen (both the launcher's `GUI — character select` flow and the `ACDREAM_RETAIL_UI=1`/`ACDREAM_OPEN_CHARGEN=1` dev shortcut) plus Create's enable state and the Exit/Back return path; §CC2 the six-page flow per page (the AP-214-retired opening roll + its gender-flip quirk, Random on each page, the nine known Appearance-page cosmetic gaps called out by number so they aren't mis-filed as new bugs); §CC3 every Finish outcome (happy path, NameInUse + the AD-100 double-send log note, the credit-warning confirm flow, the randomize-warning flow, the exit-warning flow, NameTooLong); §CC4 the two ACE-side landmines (the Arcane Lore over-deduction, MEASURED latent per the plan's risk item 8; disabled-Olthoi → Pending → NameDBDown, retail-correct); §CC-Not-Automated stating plainly that no automated create has touched a live ACE server — this gate is the first one. **Test deltas (Release):** Runtime 1735/0 (was 1726/0, +9: the full-field decode test, the 6-case rejection-code Theory, 2 launcher-payload tests), App 5256/3 skips (was 5254/3, +2: the Create-ghosting test, the cross-controller round-trip test), Headless 166/0 (unchanged), Launcher.Core 324/0, Launcher.Tests 67/0 (one earlier standalone run hit a Fail:1 Avalonia headless-platform-initialization failure that reproduced on no other run including a full-solution pass — a pre-existing environment flake, zero files under `src/AcDream.Launcher`/`tests/AcDream.Launcher.Tests` touched this slice), full solution 14,426 passed / 4 skipped / 0 failed in one complete pass across every project (Core.Net's NakEmission flake and Content's DecodedTextureCache flake did not reproduce this run either). **Review fix round (this commit, F1-F9), CC7 REVIEW-CLOSED:** F1 files AP-229 for the screen-layering divergence the reviewer flagged (retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch; acdream keeps both `CharacterManagementUiController`/`CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes), records what the reviewer confirmed already works (selection/world-name persistence, click-through isolation, one coherent `Modal` stack), and the narrow residual risk it left open (the shared `RetailDialogFactory` can hand `UiRoot.Modal` to a dialog opened by the still-ticking, occluded management screen's `ReconcileDialogs` on an inbound `CharacterError` — a race retail cannot have since the occluded screen simply does not exist there). F2 rewrites the connected-gate script's roster-full step with the exact `@modifylong max_chars_per_account` recipe (ACE default 11, confirmed against `references/ACE/Source/ACE.Server/Command/Handlers/AdminCommands.cs:4393`) and the pending-delete-counts-too note. F3 adds AP-221's exact console-diagnostic lines to §CC2's known-gaps paragraph so a session-permanent dead preview reads as a known gap, not a fresh bug. F4 adds an empty-name/AP-227 step to §CC3 so the tester expects acdream's `NoNameWarning` dialog instead of retail's silent keep-old-name behavior. F5 adds an App-layer source-text pin (`GameWindowLiveSessionOwnershipTests.LiveSessionRuntimeFactoryBindsCharacterCreatedAndCreationFailedToTheStatusWriter`) for the `CharacterCreated`/`CreationFailed` delegate wiring inside `LiveSessionRuntimeFactory.cs:229-236` the reviewer proved was deletable without breaking any test — no practical seam exists to construct the factory end-to-end without a `GameWindow` (confirmed: its one production construction site is deep inside `SessionPlayerComposition.cs`, and no test in the repo constructs it directly), so the pin follows this same test file's own established source-text pattern (`ProductionWindowConstructsOnlyTheCanonicalRuntimeRoot`, `DisplacedLifecycleBodiesAreAbsent`) rather than a contrived full construction; the exact payload SHAPE these delegates produce was already pinned separately at `SessionStatusWriterTests.CharacterCreatedAndCreationFailed_WriteThePinnedShape`, so the new test plus that existing one together cover "bound" and "correct payload." F6/F7 correct this row's own wording above (checksum-assertion circularity; fixture construction order) and strengthen `CharacterScreensFixedCanvasArbiterTests` per F7's fix. F8 records a known flake found under full-solution parallel load on both reviewer runs (passes standalone, unrelated to CC7 — an allocation assertion sensitive to concurrent load): `AcDream.Runtime.Tests.Physics.RuntimeCollisionReportingStateTests.WarmedSteadyContactRefreshDoesNotAllocate`, joining the existing Core.Net NakEmission / Content DecodedTextureCache / App SocialPanelLiveMountProbeTests known-flake set. F9 adds a one-line note to §CC2's Heritage-page Random step that a uniform pick over 13 heritages can repeat the current one. **Campaign status: all seven slices (CC1-CC7) are REVIEW-CLOSED; the campaign is CODE-COMPLETE pending the user's own connected gate** (`docs/research/2026-08-16-campaign-cc-test-script.md`) — no automated live character creation has touched ACE yet; that gate remains the sole outstanding acceptance step. | +| CC6b-MOUNT | CODE-COMPLETE 2026-08-15 (the page-mount half CC6b-PRE deferred — Appearance page, spin controls, color-wheel family, viewport wiring — landing after CC4 merged, closing out Campaign CC's CC6 slice); REVIEW-CLOSED 2026-08-15 (dual-lens re-review of the F1-F13 fix round returned NOT CLOSED with residuals R1-R3 + 2 nits, all fixed this round, re-reviewer pre-authorized a diff-check-only close) | `34c6fceab0bc300ab638339b88c5e5f98ae4d724`, `d2a71152`, (this commit — the R1-R3+nits closeout) | CLOSED (dual-lens: architectural PASS-with-items, retail-fidelity FAIL → F1-F13 fix round `d2a71152` → narrow re-review: F1-F13 verified against the decomp, residuals R1-R3 + 2 nits → this commit; re-reviewer pre-authorized diff-check-only close) | **Appearance page** (`CharacterCreationAppearancePage`, `src/AcDream.App/UI/Layout/`, wired into `CharacterCreationUiController` beside the four sibling pages): gender buttons (`0x100003a7`/`a8` -> `SelectGender(2)`/`SelectGender(1)`, decomp `ListenToElementMessage` cases `0x9d`/`0x9e`); Face/Clothes sub-tabs (`0x100003a9`/`aa`, cases `0x9f`/`0xa0`) toggling the `0x100003ae`/`b4` choice containers and defaulting the "current part" to Hair/Headgear respectively; nine spin controls (hair/eyes/nose/mouth/skin `0x100003af-b3`, headgear/shirt/trousers/footwear `0x100003b5-b8`) reproducing retail's two-arrow-plus-body-click composite through `UiButton.OnClickAt`'s local x coordinate — decrement zone x=[80,127), increment zone x=[127,174), else selects the part with no index change (cases `0xa5-0xa9` and their headgear/shirt/trousers/footwear mirrors) — since `DatWidgetFactory` consumes each spin's two locally-reused arrow children (`0x1000030a`/`0x1000030b`) into ONE flat `UiButton` with no separate addressable arrow widget; nine color swatches (`0x1000030f-0x10000317` -> `SetColor(0..8)`, gated on the current part's own color-list length exactly like retail's `iNumColors > N` check); the shade scrollbar (`0x10000321`) bound via `ScalarChanged`; zoom/rotate buttons delegating to a late-bound `IChargenPreviewControl` seam. **Per-part routing table** (`StyleSlotFor`/`ColorSlotFor`/`ShadeSlotFor`), decomp-derived from `SetColor @0x0047DD50` and `SetShade @0x0047C860`: Hair has its own color AND shade; Eyes has color but NO shade (retail's `SetShade` switch has no case 1 — independently confirmed against CC6a's own "eye color has no shade indirection" finding); Nose/Mouth/Skin have NO color and ALL route their shade to SKIN shade (cases 2/3/4 share one decompiled body — a genuine retail quirk, not a porting shortcut); Headgear/Shirt/Trousers/Footwear each have their own color and shade. **Wrap semantics** (`CharacterCreationAppearancePage.CycleIndex`, internal static, unit-tested via 10 `[Theory]` cases): plain `[0,count)` modulo wrap for every style spin except Headgear; Headgear alone gets the decomp-derived `(count+1)`-position RING including the `Unset` ("no headgear") position — `CharGenState::SetHeadgearStyle`'s literal signed-int32 comparison shape (`0x0047F4B5`-`0x0047F530` decrement, `0x0047F7D8` increment): decrementing FROM style 0 lands on Unset, incrementing FROM Unset lands on style 0, decrementing FROM Unset wraps to the LAST style, incrementing past the last style lands on Unset — a real closed ring of `count+1` positions, not a plain wrap. **Review fix round F1 correction (2026-08-15):** every OTHER style spin ALSO has a decomp-observable Unset-cycling case, in the SAME switch the headgear ring was ported from — the shared decrement tail (`label_47f065`/`label_47f6d9`, reached from Hair's own decrement case `@0x0047f465-0x0047f486` and inlined per-part for Eyes/Nose/Mouth/Shirt/Trousers/Footwear) computes `new = cur - 1` on the raw signed int32 (Unset = -1), giving `new = -2`, which wraps to `count - 1` — the SAME "wrap to the last index" shape headgear's own ring uses. Incrementing from Unset (`new = -1 + 1 = 0`) was already correct in acdream. The original claim here ("no decomp-observable Unset-cycling case... starts at style 0 for BOTH directions") is WRONG for decrement; fixed in `CharacterCreationAppearancePage.CycleIndex` and its own corrected doc comment. **Heritage 6/0xc/0xd gate** (`gmCGAppearancePage::Update @~0x0047EB46-0x0047EE95`): Gearknight/Olthoi/OlthoiAcid hide the Clothes sub-tab (making all four clothing spins unreachable, matching the OWED item's "four clothing spins hidden" framing through retail's OWN mechanism — hiding the tab, not each spin individually) plus the Nose/Mouth spins directly, and disable the Eyes spin's arrows (`_eyesArrowsDisabled`, since Olthoi/Gearknight forms have fixed eyes); **review fix round F3 correction (2026-08-15):** forces `SetChoice(FACE)`/`SetSelection(HAIR)` UNCONDITIONALLY whenever the gate engages (`@0x0047eac6/0x0047eacf` Gearknight, `@0x0047ee32/0x0047ee3b` Olthoi/OlthoiAcid) — NOT only when Clothes happened to be showing, the original (wrong) framing here. A conditional gate left Nose/Mouth as the current part when the Face tab was already active, stranding the shade control on a now-hidden part; retail always snaps back to Hair. **Preview wiring** (`ChargenPreviewController`, `src/AcDream.App/Rendering/`, new): bridges a real architectural gap the CC6a/CC6b-PRE foundation left open — `ChargenPreviewRenderer` only ever built its OWN private `ChargenPreviewCamera` with no injection seam, but `ChargenPreviewZoomController` needs a SETTABLE camera to tween. Fixed at the root: `ChargenPreviewViewportCamera` gained a `ChargenPreviewCamera`-accepting constructor overload, `ChargenPreviewRenderer` gained an optional `camera` parameter using it, and `ChargenPreviewController` owns the ONE shared `ChargenPreviewCamera` instance handed to both. `ChargenPreviewController` consolidates the per-frame `IPrivateEntityViewportFrame` owner role (mirrors `PaperdollFramePresenter`, self-timing via `Stopwatch` rather than touching the shared frame-phase interface) with the `IChargenPreviewControl` seam the page's buttons bind against (constructed before the graphics backend exists, so the page cannot receive the real renderer at construction time — assigned late by `LivePresentationComposition`, exactly mirroring the paperdoll's own late `viewport.Renderer = ...` assignment). `Rebuild` recomposes via `ChargenAppearanceFactory.TryCompose` + `ChargenPreviewEntityBuilder.TryBuildAnimated` on ANY heritage/gender/appearance-selection change (no-op if identical to the last composed selection) but only SNAPS the camera to the heritage's default eye on a HERITAGE OR GENDER change (decomp-cited: `gmCGAppearancePage::Update`'s only two confirmed direct call sites are `InitializePage` and the two gender-button handlers; spin/color/shade changes call the narrower `SetSelection`/`SetColor`/`SetShade`, none of which touch `m_vectCurPosition`) — a fresh `ChargenPreviewAnimator` is unavoidable on every rebuild (it owns the resolved drawable-part list, which changes with the mesh) but is immediately restored to the PREVIOUS zoom state via `SetZoomedIn`, and the CURRENT accumulated rotation heading (not the retail default) is threaded into the rebuild, matching retail's `m_bZoomedIn`/`m_fCurHeading` both living on the PAGE and surviving `Update`. Mounted as the THIRD private creature viewport beside paperdoll/creature-appraisal: `RetailUiRuntime` gained `ChargenPreviewViewportWidget`/`ChargenPreviewControl`/`IsChargenPreviewPageVisible` (computed through `CharacterCreationUiController`'s new `AppearanceViewport`/`AppearancePreviewControl`/`IsAppearancePageVisible`, the last one gating on BOTH the page root's own Visible AND the whole screen's `Root.Visible` since `Close()` only ever hides the latter); `LivePresentationComposition` constructs the renderer+catalog+controller and wires `viewport.Renderer`/`page.PreviewControl` through the same lease/`AdoptRelease` pattern paperdoll uses; `FrameRootComposition`'s `PrivateEntityViewportFrameGroup` gained the controller as its third member; `GameWindow`/`GameWindowLifetime` gained the matching guard fields and `RenderShutdownRoots` disposal entries. **Testability seam:** `IChargenPreviewRenderer`/`IChargenPreviewFrameView` (mirroring `IPaperdollDollRenderer`/`IPaperdollFrameView`) let `ChargenPreviewControllerTests` (6 cases, installed-DAT-gated, fake renderer/view — no live GPU) exercise the REAL `ChargenAppearanceFactory`/`ChargenPreviewEntityBuilder` composition path against the installed EoR dat: same-selection no-op, heritage-change camera reset, appearance-only-change camera preservation, zoom-state preservation across an appearance rebuild, the 180° heading actually reaching the built entity's `Rotation` after `Render()`, and the invisible-page render skip. **Color-wheel scouting (campaign plan risk item 4, RESOLVED via live-DAT probe against the installed EoR dat — `CharacterCreationLiveDatTests.AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport`/`AppearancePage_SpinArrowGeometryIsUniformAcrossAllNineSpins`):** NO new `DatWidgetFactory` widget type was needed anywhere on this page. The nine swatch buttons author Type 1 -> `UiButton`; their nine Type-3 companion "selected"-ring overlays (`0x10000318-0x10000320`) and the GradCircle (`0x1000030e`) author Type 3 -> the generic `UiDatElement` fallback; the shade scrollbar (`0x10000321`) authors Type 0xB -> `UiScrollbar`, matching the decomp's own `DynamicCast(0xb)`. The nine spin containers and their two locally-reused arrow children all author Type 1 -> `UiButton`. Two narrow, DECIDED visual substitutions from this finding are filed as AP-215: swatches use their own `.Selected` highlight instead of toggling the separate companion overlay (retail's `SetColor`'s `m_tColorWheel[...]->SetVisible` mechanism), and the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name) show a 1-based ordinal instead of retail's icon thumbnail; the four clothing spins DO show their real `ChargenGearOption.Name`. **The `@140355` gender-flip-on-init oddity (campaign plan risk item 5, RESOLVED via decomp alone — no live cdb needed):** `gmCGAppearancePage::InitializePage`'s own gender-read-then-FLIP-to-the-opposite code (`~0x004802DA-0x00480303`) is real and ALWAYS fires, because `gmCharGenMainUI`'s own constructor (`~0x004e81f5-0x004e8218`, BEFORE any page constructs) calls `CharGenState::RandomizeCharacter(state, hasToD) @0x005c6d80` — retail's chargen screen is NEVER actually blank on open; it always starts with a fully random heritage/gender/appearance/clothing/template/start-area already rolled, which the Appearance page's own init code then immediately flips to the opposite gender. Filed as AP-214, the same unported-primitive gap AP-212 already tracks for the Random button (`RandomizeHeritageGroup`/`RandomizeAppearance`/`RandomizeClothing`/`RandomizeTemplate`/`RandomizeStartArea` are the SAME six primitives `RandomizeCharacter` calls) — acdream's chargen screen opens honestly blank instead, by design, this round. **AD-101 RETIRED** (register §2, 79->78 active rows): `CharacterCreationHeritagePage.Select` no longer auto-selects a gender after a heritage click — the Appearance page's real gender buttons are now the only gender-selection path, matching the review fix round's own retirement-sequencing correction (must land no later than CC5's Finish un-ghosting, which it does — CC5 has not yet un-ghosted Finish). Retail's own default is verified NOT blank (AP-214, above) but acdream's honest-blank choice is deliberate, not an oversight. Updated `CharacterCreationUiControllerTests`'s shared fixture (`FakeRuntime`/`BuildOptions`) with real non-empty Hair/Eyes/Nose/Mouth/Headgear/Shirt/Trousers/Footwear/ClothingColors lists (previously all empty placeholders — no existing test depended on the empty state) and a real `BuildAppearancePage()` layout fixture (uniform spin geometry matching the live-DAT-measured 80/127/174 zone boundaries) so the new dispatch tests exercise the SAME `OnClickAt` zone math production code uses; the one pre-existing gender-side-effect assertion (`HeritageButton_SelectsHeritage_AndAutoSelectsFirstGender`) is renamed/corrected to assert NO gender side effect. **TS-82 NARROWED** (register §4): closed out for the Appearance page specifically (now real, not content-inert) — the row now covers Summary only, CC5's remaining scope. **Register bookkeeping this commit:** AD-101 retired (row deleted, count 79->78); AP-214 filed (the `RandomizeCharacter`-at-ctor / gender-flip finding, count 149->150); AP-215 filed (the two Appearance-page visual substitutions, count 150->151); TS-82 narrowed (Summary-only, count unchanged). **Scope-addendum work (folded into this same commit, not a separate round):** `ChargenPreviewRotationController.HeadingDegrees`'s doc comment corrected to name BOTH the ctor's `0f` (`gmCGAppearancePage::gmCGAppearancePage @0x0047CDAC`) and `InitializePage`'s override to `180f` (`@0x0047FDD0`, write at `0x00480235`, pushed via `SetPlayerHeading` at `0x0048023F`) as retail's OPERATIVE starting heading; DECIDED to change the controller's own parameterless-constructor default from `0f` to a new `RetailDefaultHeadingDegrees = 180f` constant (option (b) of the two offered) rather than requiring every future mount site to remember a separate "seed to 180" call at construction — every real `gmCG3DView` owner (Appearance, Summary `@0x0047BD54` — confirmed a SEPARATE `gmCG3DView` instance/page, CC5's own scope, not touched here — and `gmBarberUI`) converges on 180° before its first visible frame, so a controller whose default silently faces the character away from the camera is exactly the trap the addendum warned about; existing pure-math tests updated to pass `0f` explicitly (keeps their relative-delta assertions simple and unchanged in meaning) plus one new test pinning the parameterless-constructor 180° default at the seam a real consumer experiences, and a second, end-to-end confirmation inside `ChargenPreviewControllerTests` that `Render()` actually applies that heading to the built entity's `Rotation`. **Tests:** `CharacterCreationLiveDatTests` (+2 permanent structural/geometry tests replacing the temporary scouting probe), `CharacterCreationUiControllerTests` (+23: gender/spin/wrap/swatch/shade/zoom-rotate dispatch, the Olthoi clothing-hide gate, the 10-case `CycleIndex` wrap-semantics theory, the renamed AD-101 test), `ChargenPreviewControllerTests` (+6, new file, installed-DAT-gated), `ChargenPreviewRotationControllerTests` (+1, the 180°-default pin). Counts (Release, full solution, `ACDREAM_PROBE_LIVE_MOUNT=1` + `ACDREAM_DAT_DIR` set so every installed-DAT-gated test in this round actually runs rather than skip-gating): Runtime 1713/0 (unchanged — `SetAppearanceIndex`/`SetShade` command plumbing already existed in `IRuntimeCharacterCreationCommands`/`GameRuntimeCommands.cs` from CC3, nothing new needed there), Core 4786/1 skip (unchanged), Content 147/0 (unchanged), App 5220/3 skips (5208/15 skips without the probe env vars — the 12-skip delta is exactly the installed-DAT-gated tests this round adds/exercises), Headless 166/0 (unchanged) — zero failures across two consecutive full-solution runs; one transient failure in `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` reproduced on the FIRST full-solution run and passed clean both in isolation and on an immediate full-solution re-run — the SAME pre-existing, previously-documented flake CC6b-PRE's own ledger row already names (randomized-loss-injection timing, zero files under `src/AcDream.Core.Net/` touched this round either). **OWED for CC5+ / future:** the actual retail-icon rendering pipeline for hair/eyes/nose/mouth style spins (AP-215's own icon-label half) and the GradCircle's own retail-driven repaint (review fix round correction 2026-08-15: AP-215 does NOT name the GradCircle — that was this ledger row's own false claim; the GradCircle gap is filed separately as AP-217, REWRITTEN 2026-08-15 at the re-review of `d2a71152` (R3) after re-deriving from the decomp: `gmCGAppearancePage::ListenToElementMessage`'s own dispatch switch has NO case for the GradCircle's offset at all, so it is not a click target in retail either — `DoGradDisk` is a PAINT-only routine that blits the gradient art tinted with the current part's color (or blanks it for Eyes) whenever `SetColor`/`SetSelection` run; acdream's gap is that it never repaints the GradCircle at all, a cosmetic paint gap rather than a dead click target, and the nine swatch buttons already provide the full, decomp-cited color-selection INPUT path); a real `RandomizeCharacter` port (AP-214/AP-212's shared landing site) if a future connected gate wants retail's true randomized-on-open default instead of acdream's honest-blank one; the exact pixel-identical companion-overlay swatch highlight (AP-215) if a future visual gate demands it; **the current-part spin highlight itself, newly measured DEAD for all nine spins (AP-222, filed at the re-review of `d2a71152`, N2)** — none of the nine spins author Highlight-state media, so `RefreshColorAndShadeControls`'s `TrySetRetailState(Highlight)` call silently never changes what's drawn; unresolved whether retail's own spin art has the same gap or uses a different mechanism entirely, needs a decomp read of the real per-frame spin-face renderer before deciding a fix. | +| Gate round 1 | CLOSED 2026-08-16 (batches A-G plus a dedicated closeout round; supersedes this ledger's "sole remaining acceptance step" framing above — that framing predates the user's connected gate, which found the six-page findings batch GF-1..GF-16, then re-tested and found R2-1..R2-8, both fully fixed across this round) | Batches: `1d9de5e0` (A — GF-15 input/GF-5 skills rows/GF-13 GM toggles), `7d09821f` (B — authored selection states/label state/zoom-swatch feedback), `0591b9a0`+`5190e169`+`2349f8b4` (C — rich text/labels/backdrops, client-wide un-consume carve-out, Summary how-to+scrollbar), `63bf64c9` (D — gmCG3DView environment backdrop), `e24ec208` (E — text origin/caption escapes/value rects/scrollbars/name prefill), `8c30aa18` (F — Skills page buckets/selection/info box/cost text/arrow states, partial — the four-bucket model itself deferred to the closeout below), `834c2547` (G — real color wheel DoColorSpots/DoGradDisk color computation, left INERT pending the closeout's wiring). Closeout round (this session, dedicated Sonnet implementer): `e1d7d095` (Group 1 — wires Batch G's two STOPPED items: `UiButton`/`UiDatElement` gain a `Tint` property, the flat-fill overlay is replaced by a genuine multiplicative sprite tint, and the DAT-backed color-source seams are threaded through the composition root), `0fed5fdd` (Group 2 — the Skills page four-bucket sorted model Batch F deferred: `ChargenSkillDetail`/`ChargenSkillFormula` thread `SkillBase.MinLevel`/`Description`/`Formula`, `CharacterCreationSkillsPage` groups/sorts/re-buckets, the info box gets its description+formula completion), `bd359d51` (Group 3 — the round review's remaining findings F4-F11/F14/F16: three UiButton corpus sweeps, a narrow `AuthoredInvisible` honor for the chat new-text indicator, `BoundedProcessOutputCapture`'s single-write `AppendLine`, a stale-comment correction, documented (not code-changed) numeric-asymmetry and harmless-set-membership findings, per-page `DatRichText.Compose` caching, and the Summary preview's own render-id pair closing a real cross-page `TextureCache` collision). Docs-only bookkeeping (register/ISSUES/findings-doc corrections for F3/F12/F15) lands in the commit immediately following this ledger update. | Register bookkeeping across the round: AP-216/AP-217 RETIRED (Group 1), AP-213 RETIRED (Group 2), AP-229/AP-230 amended with closeout addenda (F3/F5-F6), the AP section header's inverted "one high" note corrected to "one low" (F12), the AD section header recounted 77->79 (F12); new AP-231 documents the Skills page formula-connector-text approximation. Gates: full-solution Release build green throughout; App suite (live-DAT env) 5358/3, Runtime 1735/0 (unchanged), Core 4797/1, Content 154/0, Launcher.Core 338/0, and the complete solution (12 test projects) 0 failures / 4 skips at the closeout's own final run. | **USER-PASSED 2026-08-16 on build `1.0.2-cc.o`** after two further re-test fix rounds this ledger row predates: re-test 2 (`7d6a7898`..`91f84dec`, R3-1..R3-9 — one-line captions per retail's OneLine gate, info-pane VJustify scoped fix + #410/AD-104, the single-sprite scrollbar-thumb fallback, retail's ReplaceColor spot bake replacing the tint approximation, the third exhaustive `[ Name ]` negative) and re-test 3 (`e6acb800`, R4-1..R4-4 — the base-inherited value-child reflow, the DrawTiled→single thumb marker, the pane-taller-than-frame clamp + AD-105, the PreserveEndOnLayout first-overflow pin). Residual notes carried: F3's literal ask (a test driving `RetailUiRuntime.Tick(double)` itself rather than its two components separately) was assessed and NOT implemented — `RetailUiRuntimeBindings` requires ~24 nested sub-binding records with no existing lightweight construction path, disproportionate to the value of strengthening an already-correct, already-tested tick-order guarantee (`Finish_EmptyName_RealEventPath_...` already pins the same order via direct calls); AP-231's formula-connector approximation remains unverified against a live retail capture. | diff --git a/docs/research/2026-08-14-campaign-la-handoff.md b/docs/research/2026-08-14-campaign-la-handoff.md new file mode 100644 index 00000000..8dcc588b --- /dev/null +++ b/docs/research/2026-08-14-campaign-la-handoff.md @@ -0,0 +1,284 @@ +# Campaign LA — session handoff (2026-08-14) + +**Read this first, then `docs/plans/2026-08-14-launcher-campaign.md` (the plan + +ledger), then `docs/superpowers/specs/2026-08-14-launcher-campaign-design.md` +(the approved design).** Memory crib: +`claude-memory/project_launcher_direction.md`. + +## Worktrees (full paths — work in the campaign worktree, NOT the repo root) + +| Purpose | Full path | Branch | HEAD at handoff | +|---|---|---|---| +| **Campaign branch — START HERE** | `C:\Users\erikn\source\repos\acdream\.claude\worktrees\acdream-launcher-credentials-4d2f7c` | `claude/acdream-launcher-credentials-4d2f7c` | `75a6724d` | +| LA2 slice | `C:\Users\erikn\source\repos\acdream\.claude\worktrees\acdream-la2` | `campaign-la2` | `c6019424` | +| LA3 slice | `C:\Users\erikn\source\repos\acdream\.claude\worktrees\acdream-la3` | `campaign-la3` | `26feba81` | +| LA7a slice (merged — removable) | `C:\Users\erikn\source\repos\acdream\.claude\worktrees\acdream-la7a` | `campaign-la7a` | `0c8643a7` | + +The repo root `C:\Users\erikn\source\repos\acdream` is on `main` and is NOT +where this campaign happens. + +--- + +## 1. What Campaign LA is + +One external product — the **acdream launcher** — that is simultaneously the +installer, the updater, and the multi-server / multi-account / multi-character +session launcher (ThwargLauncher UX model), on **Windows and Linux**; plus the +one client-side feature the launcher flow exposes as missing, the **retail +character-management screen**. + +Design decisions already made and NOT to be re-litigated (spec §2): + +- Avalonia UI; `AcDream.Launcher` (thin) + `AcDream.Launcher.Core` (BCL-only). +- **Credentials in a plaintext file — user-decided.** 0600 on Linux; never in + logs, arguments, session configs, or the status stream. +- **Approach A, file-contract orchestrator:** the launcher speaks NO game + protocol. Config file in → password via child stdin → JSONL status events + out. (Launcher embedding Runtime was REJECTED: a probe login that fails to + tear down gracefully poisons the ACE account ~3 min.) +- Full CRUD in the launcher UI; hand-editing JSON is never required. +- Character enumeration by **headless probe** (connect → CharacterList → + graceful disconnect BEFORE EnterWorld → exit) plus cache-from-observation. +- **Retail char-select has NO 3D preview** — recon-corrected. Retail's + `gmCharacterManagementUI` is a flat listbox + Enter/Delete/Restore + dialogs; + the rotating-model viewport is character-CREATION-only. Create Character is a + future campaign. +- Everything (launch + install + update) in ONE campaign. +- **Linux posture (user-directed):** the full launcher stack ships Linux-tested + in this campaign; GUI *client* launches stay Windows-only until Slice L + resumes later. The launcher renders gui/guiSelect disabled on Linux with an + explicit Slice-L note. + +--- + +## 2. Slice ledger at handoff + +| Slice | State | Commits | +|---|---|---| +| LA0 `AcDream.Platform` extraction | **DONE** (review closed) | `cb6502c8`, `a49e92df`, `7a839cba` | +| LA1 launch contract (App CLI + status writer + roster seam) | implemented; Opus review FIX-FIRST; **fix round WIP — stopped mid-task**, see §3 | `db9ad53c` (MIXED — see §4), note `e1322a06`, WIP `75a6724d` | +| LA2 probe mode + idle policy | **WIP — stopped mid-task**, see §3 | `c6019424` on branch `campaign-la2` | +| LA3 `AcDream.Launcher.Core` | implemented; review FIX-FIRST (12 findings); **fix round LANDED — all 12 fixed, 94/94 Windows + WSL**; owes narrow re-review, then merge | `37d74e44`, `26feba81` on branch `campaign-la3` | +| LA7a character wire messages | **DONE + MERGED** | `6a32f375`, `4338b1c1`, `0c8643a7`, merge `fa2de1c4` | +| LA4 Avalonia UI | not started (needs LA3) | — | +| LA5 plugin hosting | not started (needs LA1) | — | +| LA6 login commands | not started (needs LA1, LA5) | — | +| LA7b char-select state + flow | not started (needs LA1) | — | +| LA8 authored char-select screen | not started (needs LA7b) | — | +| LA9 installer / LA10 updater / LA11 closeout | not started | — | + +Register: **AD-97** filed (guid-only CharacterRestore request is an adaptation — +retail sends ≥16 bytes, we send 8; ACE ignores the tail). + +--- + +## 3. Work STOPPED MID-TASK — resume these first + +Two agents were **killed for token budget** and their partial work is +**committed as clearly-marked WIP**. Build/test state at both WIP commits is +UNVERIFIED — build and test before trusting either. + +1. **LA1 fix round — WIP at `75a6724d`** (campaign worktree/branch). + DONE in the WIP: F1 best-effort `SessionStatusWriter` (never throws into + login/teardown, creates its parent directory), F2 App reader tolerance + (parse-and-ignore `process.paths`, explicit named refusal of + `mode: "probe"`), F5 `--session-config` argument hardening, new tests. + **STILL OWED:** F4 production-shape the shared fixture + (`tests/Fixtures/campaign-la/session-config-shared-fixture.json` — add + `process.content`, switch credential to `standardInput`/`session`, assert + values on BOTH sides; this was the agent's literal next step); F3 reconnect + emits `disconnected` before the second `connected` + record the mid-play + wire-drop limitation in the plan's status-stream section; F6 `exited` + idempotency + distinct reason strings; F7 make the Runtime redaction test + structural (assert the exact serialized property set per event kind); F8 + platform-guard file-set test + fix the overstating `isLinux` comment; + optional `RuntimeOptions.PrintMembers` redaction of `LivePass`. + Then: run Runtime/App/Headless Release suites (+ WSL for Runtime/Headless) + and dispatch the NARROW re-review. +2. **LA2 — WIP at `c6019424`** (worktree `...\acdream-la2`, branch + `campaign-la2`). DONE in the WIP: probe flag through + `LiveSessionConnectOptions`, the `StartCore` short-circuit before selection, + the `mode` field with the `JsonRequired`→semantic-validation move, host + exit-code mapping, 34 tests passing including 3 probe tests (agent's last + report before the stop). **STILL OWED:** idle-policy unit tests (its next + step), full Runtime+Headless Release suites on Windows AND WSL, then the + Opus dual-lens review. +3. **LA3 fix round — COMPLETE at `26feba81`** (worktree + `.claude/worktrees/acdream-la3`, branch `campaign-la3`). All 12 findings + fixed: the CRITICAL `"paths": {}` emission (now omitted entirely), probe + composition (`ComposeProbe` + `mode` field), graceful stop (Linux SIGINT via + `libc kill`, Windows gap filed as **ISSUES #397** with the + CREATE_NEW_PROCESS_GROUP + CTRL_BREAK direction), 0600 temp-file window, + the Launcher.Core dependency-boundary guard, non-throwing parser/tailer, + monotonic supervisor state, `0x`-prefix id parsing, uint + `SecondsGreyedOut`, and `MalformedStatusEvent`. 94/94 Windows AND WSL. + **NEXT: narrow re-review of `26feba81`, then merge `campaign-la3`** (with + the two owed merge items below). + +**To recover:** `git -C log --oneline -3` and `git status` per +branch. If a fix round committed, run its narrow re-review; if it did not, +re-dispatch it from the finding list above (the reviews' full text is in the +original session transcript, but the finding summaries here are sufficient to +re-derive the work). + +**Owed at merge time (do not lose these):** +- **Cross-assembly contract test** when LA1+LA3 meet: feed an + `AcDream.Launcher.Core` composer-produced document to BOTH host loaders + (App + Headless) and assert it parses. This is the permanent anti-drift + enforcement for the pinned contract. +- **CI lane**: add `tests/AcDream.Launcher.Core.Tests` to + `.github/workflows/headless-portability.yml` (both `paths:` filters + the + Linux test array), mirroring what LA0 did for `AcDream.Platform.Tests`. +- After LA2 lands, App's reader must refuse `mode: "probe"` (covered by LA1 + fix-round F2 — verify it actually landed). + +--- + +## 4. Landmines / lessons from this session + +1. **Never run git state commands in a worktree while an implementer agent is + live in it.** `git add ` scopes the ADD; `git commit` commits the whole + INDEX. A docs commit swept 37 in-progress LA1 files into `db9ad53c`; the + marker commit `e1322a06` documents it. Memory: + `claude-memory/feedback_no_commits_beside_live_agents.md`. +2. **Auto-created agent worktrees can be based on stale history.** The first LA3 + dispatch landed on a spell-bar-era commit. Always create the worktree + yourself from the campaign HEAD and make the agent verify its base commit as + its first action. +3. **PowerShell 5.1 mangles double quotes inside heredoc commit messages** — + keep git commit bodies quote-free. +4. **The pinned contract must live on disk, not in agent prompts.** It now does + (plan §"Pinned launch-contract schema (v1, BINDING)"). The LA3 CRITICAL was + a direct consequence of it living only in prompts. +5. **Reviews have caught something tests could not, four slices running:** lost + Linux CI lanes (LA0), a real-but-mislabeled wire deviation (LA7a → AD-97), a + cross-worktree contract break (LA3), an observability sink that could fail + the transaction it observes (LA1). Do not downgrade the review step. + +--- + +## 5. How we work (binding process) + +- **Fable plans, sequences, integrates. Sonnet implements bounded slices. Opus + reviews every slice boundary, dual-lens:** (a) architectural — ownership, + layering, dependency-guard integrity, seams; (b) retail fidelity against + `docs/research/named-retail/` wherever the slice touches retail behavior. + Findings → fix round → NARROW re-review of the fixes → slice DONE in ledger. +- Max 3–4 agents in parallel INCLUDING children; subagents never spawn + subagents. Every implementer prompt carries: spec+plan paths, files to read + first, the pinned contract text if relevant, acceptance criteria, commit + style, and a base-commit verification as its first action. +- One implementer per worktree; that agent owns the worktree's git index. +- `dotnet build` + `dotnet test` green before a slice is DONE; ≥1 commit per + slice tagged `Campaign LA`; every retail deviation adds its + `docs/architecture/retail-divergence-register.md` row in the same commit; + **no workarounds without explicit user approval**. +- Linux: every slice touching Launcher.Core/Headless/Runtime/Bake/Platform runs + its test projects under WSL or native Ubuntu before it is DONE. +- The ONLY stop-and-wait is a user connected/visual gate. Everything else is + Claude's call — never present the user a work-order menu. +- Keep the plan ledger, `docs/plans/2026-04-11-roadmap.md`, the CLAUDE.md + Current-state pointer, and `claude-memory/` current as slices land. + +--- + +## 6. Kickoff prompt for the new session + +Paste this as the FIRST message of the new session (it names the three +resumable work items explicitly), then set the goal in §7. + +```text +Resume Campaign LA (the acdream launcher). Work in +C:\Users\erikn\source\repos\acdream\.claude\worktrees\acdream-launcher-credentials-4d2f7c +on branch claude/acdream-launcher-credentials-4d2f7c. Read +docs/research/2026-08-14-campaign-la-handoff.md first, then the ledger in +docs/plans/2026-08-14-launcher-campaign.md. + +Three items are waiting, all recoverable from git — two are partial work from +agents that were stopped mid-task for token budget, and their build/test state +is UNVERIFIED: + +1. LA1 fix round — WIP commit 75a6724d on this branch. An agent had completed + findings F1 (best-effort SessionStatusWriter that never throws into the + login/teardown transactions and creates its parent directory), F2 (App + reader tolerates process.paths and explicitly refuses mode:"probe"), and F5 + (--session-config argument hardening). It was stopped just as it started F4. + Finish: F4 production-shape tests/Fixtures/campaign-la/session-config-shared-fixture.json + (add process.content, switch the credential to standardInput/session, assert + values in BOTH host suites), F3 (reconnect emits disconnected before the + second connected; record the mid-play wire-drop limitation in the plan's + status-stream section), F6 (exited idempotency + distinct reason strings), + F7 (make the Runtime redaction test structural — assert the exact serialized + property set per event kind), F8 (platform-guard file-set test + fix the + overstating isLinux comment), and optionally redact LivePass from + RuntimeOptions.PrintMembers. Then build, run Runtime/App/Headless Release + suites plus WSL for Runtime/Headless, and dispatch the narrow re-review. + +2. LA2 — WIP commit c6019424 in worktree + C:\Users\erikn\source\repos\acdream\.claude\worktrees\acdream-la2 (branch + campaign-la2). An agent had implemented the probe flag through + LiveSessionConnectOptions, the StartCore short-circuit before selection, the + mode field with the JsonRequired-to-semantic-validation move, and the host + exit-code mapping, with 34 tests green including 3 probe tests. It was + stopped before writing the idle-policy unit tests. Finish those, run the + Runtime+Headless Release suites on Windows and WSL, then dispatch the Opus + dual-lens review. + +3. LA3 — COMPLETE at 26feba81 in worktree + C:\Users\erikn\source\repos\acdream\.claude\worktrees\acdream-la3 (branch + campaign-la3). All 12 review findings fixed, 94/94 Windows and WSL. It needs + only a narrow Opus re-review of 26feba81 against the finding list in §3 of + the handoff, then merge into the campaign branch. + +At the LA1+LA3 merge, do not lose the two owed items: the cross-assembly +contract test (feed a Launcher.Core composer document to BOTH host loaders) and +adding tests/AcDream.Launcher.Core.Tests to the Linux CI lane in +.github/workflows/headless-portability.yml. + +After those land, continue the ledger: LA4 (Avalonia UI), LA5 (plugin hosting), +LA6 (login commands), LA7b (char-select state+flow), LA8 (authored screen), LA9 +(installer), LA10 (updater), LA11 (closeout). +``` + +## 7. The goal to set + +Set this with `/goal` in the new session (it is the same directive this session +ran under, refreshed for the current state): + +```text +GOAL: Ship Campaign LA — the acdream launcher/installer/updater + retail character-select screen. + +Work in C:\Users\erikn\source\repos\acdream\.claude\worktrees\acdream-launcher-credentials-4d2f7c +(branch claude/acdream-launcher-credentials-4d2f7c). Start at +docs/research/2026-08-14-campaign-la-handoff.md, then the ledger in +docs/plans/2026-08-14-launcher-campaign.md. Finish the three waiting items +first — LA1 fix round (WIP 75a6724d, unverified), LA2 (WIP c6019424 on +campaign-la2, unverified), LA3 (complete at 26feba81 on campaign-la3, owes a +narrow re-review then merge) — then continue slice by slice. + +Process, per slice: +1. Fable plans/sequences/integrates — never present work-order menus; pick and announce. +2. Sonnet subagents implement bounded slices. Each prompt carries spec+plan paths, the + exact files to read first, the pinned contract text when relevant, acceptance criteria + (build+test green), commit style, and a base-commit verification as first action. + Subagents may not spawn subagents. Hard cap 3-4 agents in parallel including children. + One implementer per worktree; that agent owns the index — the orchestrator makes no git + state changes in a worktree while an agent is live in it. +3. Every slice boundary gets an Opus dual-lens review: (a) architectural — ownership, + layering, dependency-guard integrity, seams; (b) retail fidelity against + docs/research/named-retail/ wherever the slice touches retail behavior. Fix findings, + then a narrow re-review of the fixes. +4. dotnet build + dotnet test green before any slice is declared done; Linux (WSL or + native Ubuntu) runs for every slice touching Launcher.Core/Headless/Runtime/Bake/ + Platform. One commit per slice minimum, tagged "Campaign LA". Any retail deviation adds + its divergence-register row in the same commit. No workarounds without explicit approval. +5. As slices land: update the plan ledger, docs/plans/2026-04-11-roadmap.md, the CLAUDE.md + Current state pointer, and claude-memory/. +6. The only stop-and-wait is a user connected/visual gate (launch modes vs local ACE, + character-probe round-trip, char-select visual matrix, first-run wizard, update swap). + When a slice reaches one: write the exact test script under docs/research/, announce the + gate, and keep driving any slices not blocked behind it. + +DONE = all slices code-complete, automated gates green, dual reviews closed, and the +connected-gate checklist delivered to the user in one script document. +``` diff --git a/docs/research/2026-08-14-campaign-la-test-script.md b/docs/research/2026-08-14-campaign-la-test-script.md new file mode 100644 index 00000000..cd8f68b0 --- /dev/null +++ b/docs/research/2026-08-14-campaign-la-test-script.md @@ -0,0 +1,631 @@ +# Campaign LA11 — automated preflight and connected user gate + +**Status:** implementation checkpoint only. Run this script after the reviewed +LA10/LA11 commits are integrated and the campaign branch is clean. Campaign LA, +the Linux graphical client, and issue #397 remain open until the user records a +verdict for every applicable row below. + +This is the single Campaign LA operator script. The automated section is +display-free and connection-free. Rows A–I are deliberately manual and serial: +they use real retail DATs, a local ACE server, user-entered credentials, and +visual judgment that automation cannot supply. + +## 1. Safety boundary and required inputs + +Use placeholders throughout; never paste a password into a terminal, this +document, a screenshot, or a gate report. + +- ``: a clean Campaign LA worktree at the exact commit + under test. +- ``: a read-only source containing + `client_portal.dat`, `client_cell_1.dat`, `client_highres.dat`, and + `client_local_English.dat`. +- ``, ``, and ``: a local ACE endpoint and + account. Enter the account password only in the launcher's masked Password + field. The launcher intentionally stores it as plaintext in the **isolated** + `launcher-profiles.json`; children receive it through redirected stdin. +- ``: a second user-controlled character that can observe a + private `/tell` from each play mode. +- ``: a server-operator-approved disposable character. + Never substitute a primary character. If none exists, provision one with the + local server's normal admin procedure before row G. +- Windows 11 x64, PowerShell 7, .NET 10 SDK, a local ACE server, and a supported + Vulkan Windows machine for rows A–H. Ubuntu x64 with PowerShell 7 and a Linux + desktop/WSLg is required for row I. The Avalonia launcher is supported on + Linux; `gui` and `guiSelect` **client** actions must remain disabled with the + Modern Runtime Slice-L explanation. + +Close every unrelated `AcDream.App`, `acdream-headless`, and acdream launcher +before starting. Do not run another acdream gate in parallel. All generated +files must stay below one new gate directory; the canonical `%APPDATA%`, +`%LOCALAPPDATA%`, and XDG acdream roots are out of scope. + +## 2. Automated preflight — no UI, connection, credential, or bake + +In PowerShell 7 on Windows: + +```powershell +$Repo = [IO.Path]::GetFullPath('') +$Stamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss') +$Gate = Join-Path $Repo "logs/campaign-la-user-gate-$Stamp" +$Preflight = Join-Path $Gate 'automated-preflight' +New-Item -ItemType Directory -Path $Gate | Out-Null + +pwsh -NoProfile -File (Join-Path $Repo 'tools/run-campaign-la-preflight.ps1') ` + -Repository $Repo ` + -AllowedOutputRoot $Gate ` + -OutputDirectory $Preflight + +$Report = Get-Content -LiteralPath (Join-Path $Preflight 'report.json') -Raw | + ConvertFrom-Json +if (-not $Report.success -or $Report.dirty) { + throw 'Stop: automated preflight failed or recorded a dirty worktree.' +} +if ($Report.head -cne (git -C $Repo rev-parse HEAD).Trim()) { + throw 'Stop: preflight HEAD does not equal the current HEAD.' +} +``` + +The expected matrix is: + +| Platform | Automated command group | Required result | Typical time | +|---|---|---|---:| +| Windows | Release `AcDream.slnx` build, `-m:1` | exit 0 | 5–15 min | +| Windows | complete Release solution test, serial | exit 0; ordinary known skips only | 20–60 min | +| Windows | focused Launcher.Core update tests and launcher update/startup-option tests | exit 0 | 1–4 min | +| Windows | canonical portable project build/test closure plus Headless `--help` and empty-config `validate` from `headless-portability.yml` | every project/CLI row exits 0 | 10–25 min | +| Windows | self-contained single-file launcher publish for `win-x64` and `linux-x64` | launcher + bake roots present, no root DLL fallback | 3–10 min | +| Windows | native launcher `--verify-publish` and bake `--help` with bogus `DOTNET_ROOT*` | both exit 0 | <1 min | +| Ubuntu/WSL | run the same helper natively from the Linux path to the worktree | Linux RID report and every row exit 0 | 35–90 min | + +`report.json` records the tested HEAD/dirty state, OS/RID, exact commands, +durations, exits, redacted logs, and SHA-256/size inventory. A normal preflight +plans 32 rows, including the connection-free PID/status/redaction and script- +safety contract suites. `-AllowedOutputRoot` must be a fresh, explicit +`campaign-la-*` gate root (or the repository `logs` root), and output must be a +fresh, empty, non-reparse strict descendant; repository, home, source, payload, +nonempty, and arbitrary existing directories are rejected. The helper never +launches App or Headless in connected mode and never reads a credential. Every +child starts with all inherited `ACDREAM_*` +variables removed, so a developer shell cannot accidentally enable live, +installed-DAT, fixture-regeneration, or diagnostic gates. Only the optional +row below adds the two named DAT variables back for its three exact tests. + +### Optional installed-DAT read-only row + +This is not a bake and must not replace row A. Add the switches below only when +the DAT directory may be read by tests: + +```powershell +pwsh -NoProfile -File (Join-Path $Repo 'tools/run-campaign-la-preflight.ps1') ` + -Repository $Repo ` + -AllowedOutputRoot $Gate ` + -OutputDirectory (Join-Path $Gate 'automated-preflight-with-dat') ` + -IncludeInstalledDat ` + -InstalledDatDirectory '' +``` + +The mandatory installed-DAT result is +`CharacterManagementLiveDatTests` with both `ACDREAM_PROBE_LIVE_MOUNT=1` and +`ACDREAM_DAT_DIR` set inside the child environment. The helper reads the TRX +and fails if the test skipped or did anything other than pass. The action-map +and portal-asset probes are additional coverage, never substitutes. Expected +matrix size: 36 rows. + +On Ubuntu/WSL, invoke the same script with native `pwsh`, a Linux repository +path, and a Linux output path. Do not treat a Windows-hosted run over +`wsl.exe` as the Linux row. + +## 3. Prepare the deterministic local A/B feed + +Build distinct, version-stamped payloads so the staged launcher really changes +from A to B. These commands write only below `$Gate` (normal project `obj/bin` +incremental outputs are the already-authorized build outputs): + +```powershell +$VersionA = '1.0.1-la11.a' +$VersionB = '1.0.1-la11.b' +$Payloads = Join-Path $Gate 'update-payloads' +$Fixture = Join-Path $Gate 'update-fixture' + +function Publish-LaRelease([string]$Version, [string]$Label) { + $ClientWin = Join-Path $Payloads "$Label/client-win-x64" + $LauncherWin = Join-Path $Payloads "$Label/launcher-win-x64" + $ClientLinux = Join-Path $Payloads "$Label/client-linux-x64" + $LauncherLinux = Join-Path $Payloads "$Label/launcher-linux-x64" + + dotnet publish (Join-Path $Repo 'src/AcDream.App/AcDream.App.csproj') ` + -c Release -r win-x64 --self-contained true -p:Version=$Version ` + -o $ClientWin --nologo + if ($LASTEXITCODE) { throw "App win-x64 publish failed: $Label" } + dotnet publish (Join-Path $Repo 'src/AcDream.Headless/AcDream.Headless.csproj') ` + -c Release -r win-x64 --self-contained true -p:Version=$Version ` + -o $ClientWin --nologo + if ($LASTEXITCODE) { throw "Headless win-x64 publish failed: $Label" } + dotnet publish (Join-Path $Repo 'src/AcDream.Launcher/AcDream.Launcher.csproj') ` + -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true ` + -p:Version=$Version -o $LauncherWin --nologo + if ($LASTEXITCODE) { throw "Launcher win-x64 publish failed: $Label" } + + dotnet publish (Join-Path $Repo 'src/AcDream.App/AcDream.App.csproj') ` + -c Release -r linux-x64 --self-contained true -p:Version=$Version ` + -o $ClientLinux --nologo + if ($LASTEXITCODE) { throw "App linux-x64 publish failed: $Label" } + dotnet publish (Join-Path $Repo 'src/AcDream.Headless/AcDream.Headless.csproj') ` + -c Release -r linux-x64 --self-contained true -p:Version=$Version ` + -o $ClientLinux --nologo + if ($LASTEXITCODE) { throw "Headless linux-x64 publish failed: $Label" } + dotnet publish (Join-Path $Repo 'src/AcDream.Launcher/AcDream.Launcher.csproj') ` + -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true ` + -p:Version=$Version -o $LauncherLinux --nologo + if ($LASTEXITCODE) { throw "Launcher linux-x64 publish failed: $Label" } +} + +Publish-LaRelease $VersionA 'A' +Publish-LaRelease $VersionB 'B' + +pwsh -NoProfile -File (Join-Path $Repo 'tools/new-campaign-la-update-fixture.ps1') ` + -OutputDirectory $Fixture ` + -ClientWinX64DirectoryA (Join-Path $Payloads 'A/client-win-x64') ` + -LauncherWinX64DirectoryA (Join-Path $Payloads 'A/launcher-win-x64') ` + -ClientLinuxX64DirectoryA (Join-Path $Payloads 'A/client-linux-x64') ` + -LauncherLinuxX64DirectoryA (Join-Path $Payloads 'A/launcher-linux-x64') ` + -ClientWinX64DirectoryB (Join-Path $Payloads 'B/client-win-x64') ` + -LauncherWinX64DirectoryB (Join-Path $Payloads 'B/launcher-win-x64') ` + -ClientLinuxX64DirectoryB (Join-Path $Payloads 'B/client-linux-x64') ` + -LauncherLinuxX64DirectoryB (Join-Path $Payloads 'B/launcher-linux-x64') +``` + +The helper rejects nonempty output, invalid or non-monotonic versions, missing +root executables (including the co-deployed Bake CLI), nonabsolute inputs, +output/source overlap in either direction, and any reparse point in source or +output ancestry. It enumerates normalized relative paths with ordinal ordering, +never its own output, and normalizes ZIP origin to Unix on both hosts so +Windows/Linux hashes are identical under multiple cultures while native Linux +extraction retains 0755 for App/Headless/Launcher/Bake and 0644 for ordinary +files. It writes fixed-timestamp sorted ZIPs, +the exact LA10 v1 SHA/size manifest, `fixture-report.json`, a loopback-only +server (with optional bounded `-MaximumRequests` smoke mode), and an atomic A/B +selector. Both generated helpers reject a `-Root` other than their own fixture +directory. The generator does not download or mutate payload sources. + +Start the Windows loopback server without a shell or visible helper window: + +```powershell +$ServerInfo = [Diagnostics.ProcessStartInfo]::new() +$ServerInfo.FileName = (Get-Command pwsh).Source +$ServerInfo.UseShellExecute = $false +$ServerInfo.CreateNoWindow = $true +foreach ($Value in @( + '-NoProfile', '-File', (Join-Path $Fixture 'serve-fixture.ps1'), + '-Root', $Fixture, '-Port', '43119')) { + $ServerInfo.ArgumentList.Add($Value) +} +$FixtureServer = [Diagnostics.Process]::Start($ServerInfo) +$ManifestUri = 'http://127.0.0.1:43119/manifest.json' +if ((Invoke-RestMethod -Uri $ManifestUri).version -cne $VersionA) { + throw 'Stop: local fixture did not begin on release A.' +} +``` + +## 4. Windows isolated launcher command and evidence rule + +```powershell +$WinRoot = Join-Path $Gate 'windows-roots' +$WinConfig = Join-Path $WinRoot 'config' +$WinData = Join-Path $WinRoot 'data' +$WinCache = Join-Path $WinRoot 'cache' +$Evidence = Join-Path $Gate 'evidence' +New-Item -ItemType Directory -Path $Evidence | Out-Null + +$LauncherA = Join-Path $Payloads 'A/launcher-win-x64/acdream-launcher.exe' +$LauncherArguments = @( + '--config-dir', $WinConfig, + '--data-dir', $WinData, + '--cache-dir', $WinCache, + '--update-manifest-uri', $ManifestUri) +& $LauncherA @LauncherArguments +``` + +All four options are process-local. The three roots are an indivisible set; +the local feed reaches only the updater and is never persisted. A self-update +must preserve the same validated suffix through helper and confirmation +restarts. The launcher, profiles, installer, current-version store, updater, +session composer, and orchestrator must all use this one exact path set. + +For every play/probe row, start this gate-only PID watcher immediately before +clicking Refresh/Play. It correlates only the unique isolated session-config +path, records neither raw command line nor config contents, and must finish +while the child is still live. Its safe sidecar contains the normalized config +path, a sanitized command fingerprint, and PID plus an OS-native process-start +identity so later PID reuse cannot become a false leak: + +```powershell +$CapturePath = Join-Path $Evidence '-process.capture.json' +$CaptureStart = [DateTimeOffset]::UtcNow +$CaptureInfo = [Diagnostics.ProcessStartInfo]::new() +$CaptureInfo.FileName = (Get-Command pwsh).Source +$CaptureInfo.UseShellExecute = $false +$CaptureInfo.CreateNoWindow = $true +foreach ($Value in @( + '-NoProfile', '-File', (Join-Path $Repo 'tools/capture-campaign-la-session-process.ps1'), + '-SessionsDirectory', (Join-Path $WinCache 'launcher/sessions'), + '-CreatedAfterUtc', $CaptureStart.ToString('O'), + '-ReportPath', $CapturePath, '-WaitSeconds', '60')) { + $CaptureInfo.ArgumentList.Add($Value) +} +$CaptureProcess = [Diagnostics.Process]::Start($CaptureInfo) +# Click exactly one Refresh/Play action now, then wait for capture. +$CaptureProcess.WaitForExit() +if ($CaptureProcess.ExitCode) { throw 'Stop: live child PID capture failed.' } +$Capture = Get-Content -LiteralPath $CapturePath -Raw | ConvertFrom-Json +$SessionConfig = Join-Path $WinCache "launcher/sessions/$($Capture.sessionId)/session.json" +$Status = Join-Path $WinCache "launcher/sessions/$($Capture.sessionId)/status.jsonl" + +# After Stop and terminal status: +pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') ` + -StatusFile $Status ` + -Mode '' ` + -ProcessCapturePath $CapturePath ` + -CredentialProfilePath (Join-Path $WinConfig 'launcher-profiles.json') ` + -ExpectedSessionId $Capture.sessionId ` + -ReportPath (Join-Path $Evidence '-status.validation.json') +``` + +Add `-ExpectedPlugin acdream.smoke` to rows D–F. The validator enforces exact +v1 fields **and property order**, one session id, UTC monotonic timestamps, +mode-specific lifecycle order, exit code 0/reason, no unexpected plugin/login +command failure, exact terminal `disconnected.reason == stopped`, credential +redaction, and that exact captured process instance is gone. Independent exact +config-path correlation uses Windows CIM or Linux `/proc/*/cmdline`; it never +globally scans a process name, treats a different start identity on a reused PID +as a different process, and is unaffected by unrelated same-name processes or +Linux's 15-character names. The validator verifies owner-only profile access, +reads only password/secret fields in memory, recursively checks every allowed status +string (including command/error text), and reports only the forbidden-value +count and status hash—never credential content or a credential hash. Keep the +profile, raw `session.json`/`status.jsonl`, and raw process-capture sidecar +(which contains the absolute isolated path) local; never upload them. + +## 5. Serial Windows user rows A–H + +### A — isolated first run, real DAT bake, and release-A client baseline + +1. Confirm the launcher opens First-run setup and all launch buttons are + unavailable. Save a redacted screenshot as `A-first-run-required.png`. +2. Enter `` in the wizard, select a sensible + worker count, and click **Validate**. Confirm all four DATs pass. +3. Click **Build and install**. Do not cancel or close the launcher. The real + bake may take 30–180 minutes. Confirm every phase reaches **Completed** and + the status says `Client content installed and verified. Launch is enabled.` +4. Open **Check for updates**. Confirm available release A, click **Install + client**, and wait for `Client update installed and activated.` Do not stage + launcher A; the test launcher already has version A. +5. Confirm these exact isolated artifacts exist and no `.previous-install` + remains after success: + +```powershell +$RequiredA = @( + (Join-Path $WinData 'install.json'), + (Join-Path $WinData 'pak/acdream.pak'), + (Join-Path $WinData 'app/current.json')) +foreach ($Path in $RequiredA) { + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Missing $Path" } +} +$RequiredA | ForEach-Object { + $Item = Get-Item -LiteralPath $_ + [ordered]@{ + name = $Item.Name + size = $Item.Length + sha256 = (Get-FileHash -LiteralPath $_ -Algorithm SHA256).Hash.ToLowerInvariant() + } +} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Evidence 'A-install-hashes.json') +``` + +Do not copy `install.json` into shared evidence because it records the local DAT +path. Expected time: 45–200 minutes including bake. + +### B — server/account CRUD entirely through the UI + +1. Add `` at `127.0.0.1:`, edit its name + and port, then remove it. Confirm Cancel/Escape makes no mutation. +2. Add `` at `127.0.0.1:`. +3. Under it add `` with a user-invented throwaway field + value, edit its account name/value, then remove it. Do not reuse a real + password for this temporary row. +4. Add `` and enter its real password only in the masked field. +5. Close and reopen the launcher with the **same** `$LauncherArguments`. Confirm + only the real server/account persisted. Save redacted before/reopen images as + `B-crud-before-reopen.png` and `B-crud-after-reopen.png`. +6. Record only the profile file's size/hash, never its contents: + +```powershell +$Profile = Join-Path $WinConfig 'launcher-profiles.json' +$Item = Get-Item -LiteralPath $Profile +[ordered]@{ + size = $Item.Length + sha256 = (Get-FileHash -LiteralPath $Profile -Algorithm SHA256).Hash.ToLowerInvariant() +} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Evidence 'B-profile-hash.json') +``` + +Expected time: 10–15 minutes. + +### C — live character probe twice, no stale ACE session + +1. Select ``, click **Refresh characters**, and wait for the + probe row to finish. Confirm the roster appears without entering world. +2. Run the validator in `probe` mode for its session id. Confirm its exact + event order is `started, connected, characterList, disconnected, exited`, + with no `enteredWorld`, terminal code 0, and terminal reason `probe`. +3. In the ACE console/session administration view, confirm the account is no + longer logged in. Save a redacted `C-probe-1-ace-cleared.png`. +4. Repeat steps 1–3 immediately, producing a different session id, + `C-probe-2-status.validation.json`, and `C-probe-2-ace-cleared.png`. +5. Confirm Refresh is re-enabled and no `acdream-headless` process remains. + +Expected time: 5–10 minutes. A stale ACE account or timeout is a gate failure; +do not wait three minutes and call the next attempt a pass. + +### D — `guiSelect`, retail character screen, plugin, and login command + +1. Select one non-disposable roster character. Set its mode to `guiSelect`, + Plugins to exactly `acdream.smoke`, and its one login command to + `/tell , LA11-D-`. Save settings. +2. Click **GUI — character select**. Confirm the flat retail character list, + selection highlight, Enter button, Delete/Restore swap state, dialogs, and + absence of any invented rotating 3D preview. Save redacted + `D-character-select.png`. +3. Select the configured character and enter world. Confirm the observer gets + the exact D nonce once. Save `D-observer-tell.png` with names redacted. +4. Click **Stop** in the launcher. Confirm the game closes gracefully and ACE + releases the account. Validate `guiSelect` with + `-ExpectedPlugin acdream.smoke`. + +Expected time: 5–10 minutes. + +### E — direct `gui`, plugin, and login command + +1. Change the same character to `gui`, retain `acdream.smoke`, and change the + command nonce to `LA11-E-`. +2. Click **GUI — enter world**. Confirm it selects the exact cached character, + reaches the world, loads the plugin once, and the observer gets the E nonce + once. +3. Stop from the launcher, confirm ACE logout, and validate `gui` with the + expected plugin. Save `E-world.png`, `E-observer-tell.png`, and + `E-status.validation.json` with identifying text redacted. + +Expected time: 5–10 minutes. + +### F — headless, plugin/login command, and connected #397 acceptance + +1. Change the same character to `headless`, retain `acdream.smoke`, and use + `LA11-F-`. +2. Click **Headless**. Confirm `pluginLoaded(acdream.smoke)`, `enteredWorld`, + and the observer's single exact F nonce. +3. Click **Stop** once. On Windows this must target that child's distinct + process group with `CTRL_BREAK`; it must reach `disconnected` then + `exited(code:0, reason:graceful)` before the timeout, without a hard kill. + ACE must release the account immediately and the launcher must stay open. +4. Validate `headless` with the expected plugin and save + `F-status.validation.json` plus redacted ACE-clear evidence. + +The real automated fixture separately proves complex argv and redirected stdin +survive native `CreateProcessW`, the target receives `CTRL_BREAK`, a sibling +process group receives nothing, exit 0 precedes timeout, and `Kill` is never +called. This connected row proves the actual ACE graceful-logout half. Issue +#397 remains open if either half is missing. Expected time: 5–10 minutes. + +### G — disposable delete and restore + +1. Launch `guiSelect` for ``. Do not enter world. +2. Confirm ordinary selection enables Enter/Delete and disables Restore. Click + Delete, inspect the retail confirmation dialog, cancel once, and confirm no + state change. +3. Delete again and confirm. Verify the wait dialog, greyed/pending-delete + roster state, constant boolean-ish nonzero `secondsGreyedOut`, disabled + Enter/Delete, and enabled Restore. The UI must display no countdown. Save + `G-deleted.png`. +4. Click Restore and confirm the same GUID returns to ordinary state with + Enter/Delete enabled and Restore disabled. Save `G-restored.png`. +5. Close through launcher **Stop**, confirm graceful terminal status and ACE + release. Validate with: + +```powershell +pwsh -NoProfile -File (Join-Path $Repo 'tools/test-campaign-la-session-status.ps1') ` + -StatusFile (Join-Path $WinCache 'launcher/sessions//status.jsonl') ` + -Mode guiSelect ` + -ProcessCapturePath (Join-Path $Evidence 'G-process.capture.json') ` + -CredentialProfilePath (Join-Path $WinConfig 'launcher-profiles.json') ` + -ExpectNoEnteredWorld ` + -ExpectedSessionId '' ` + -ExpectedPlugin acdream.smoke ` + -ReportPath (Join-Path $Evidence 'G-status.validation.json') +``` + +If restore fails, stop the row, preserve evidence, and restore only that +disposable character with the server's normal admin recovery. Never continue +with another character. Expected time: 5–10 minutes. + +### H — local A→B client update, active-session refusal, rollback, self-update + +1. Record release A's `app/current.json`. Start one headless session and wait + for `enteredWorld`. +2. Switch the fixture atomically to B: + +```powershell +pwsh -NoProfile -File (Join-Path $Fixture 'set-active-release.ps1') ` + -Release B -Root $Fixture +if ((Invoke-RestMethod -Uri $ManifestUri).version -cne $VersionB) { + throw 'Stop: fixture did not switch to B.' +} +``` + +3. Open **Check for updates** and **Check again**. While the session is active, + confirm Install client, Rollback client, and Stage launcher are disabled or + refuse without changing `app/current.json`. Save `H-active-refusal.png`. +4. Stop the headless session and validate its graceful status. Install client + B. Confirm `app/current.json` names B, A is previous, all installed-file + hashes verify, and new sessions resolve from the B directory. +5. Click **Rollback client**. Confirm A becomes current and B becomes previous. + Check again and install B once more, leaving B current. Save sanitized copies + of the three pointer states as `H-pointer-a.json`, `H-pointer-b.json`, and + `H-pointer-rollback-a.json`; they contain no credentials. +6. Click **Stage launcher**. Confirm restart is required, then close the + launcher normally. The copied helper must apply B and restart the launcher + with the same config/data/cache/feed suffix. +7. Confirm profiles, install record, and update state still come from the + isolated roots; `campaign-la-fixture-release.txt` beside the relaunched + executable says `release=B`; `launcher-update/pending.json` is gone; and no + transaction backup remains. Check again and confirm launcher B is current. + Save `H-self-update-confirmed.png` and a hash-only post-state inventory. + +Never edit a manifest to force this row and never point the launcher at a +non-loopback HTTP endpoint. Expected time: 15–30 minutes. + +## 6. Row I — native Ubuntu/WSL launcher, XDG-shaped isolated roots + +Stop the Windows launcher and fixture server only after every Windows session +is terminal: + +```powershell +if (-not $FixtureServer.HasExited) { + $FixtureServer.Kill() + $FixtureServer.WaitForExit() +} +``` + +In a native Ubuntu/WSL PowerShell 7 terminal, set Linux paths. The repository +and fixture may be read from a mounted Windows path, but roots must live on the +Linux filesystem. Run the generated server natively so its `127.0.0.1` URLs +cannot escape the Linux environment: + +```powershell +$RepoLinux = [IO.Path]::GetFullPath('') +$FixtureLinux = [IO.Path]::GetFullPath('') +$PayloadsLinux = [IO.Path]::GetFullPath('') +$LinuxGate = [IO.Path]::GetFullPath('') +$env:XDG_CONFIG_HOME = Join-Path $LinuxGate 'xdg-config-home' +$env:XDG_DATA_HOME = Join-Path $LinuxGate 'xdg-data-home' +$env:XDG_CACHE_HOME = Join-Path $LinuxGate 'xdg-cache-home' +$LinuxConfig = Join-Path $env:XDG_CONFIG_HOME 'acdream' +$LinuxData = Join-Path $env:XDG_DATA_HOME 'acdream' +$LinuxCache = Join-Path $env:XDG_CACHE_HOME 'acdream' +$LinuxEvidence = Join-Path $LinuxGate 'evidence' +New-Item -ItemType Directory -Path $LinuxEvidence | Out-Null + +pwsh -NoProfile -File (Join-Path $FixtureLinux 'set-active-release.ps1') ` + -Release A -Root $FixtureLinux +``` + +Start `serve-fixture.ps1 -Root $FixtureLinux -Port 43119` in a dedicated native +terminal and leave it running. In another terminal: + +```powershell +$LauncherLinuxA = Join-Path $PayloadsLinux 'A/launcher-linux-x64/acdream-launcher' +& $LauncherLinuxA ` + --config-dir $LinuxConfig ` + --data-dir $LinuxData ` + --cache-dir $LinuxCache ` + --update-manifest-uri 'http://127.0.0.1:43119/manifest.json' +``` + +Complete this exact serial matrix: + +1. **Manual-DAT first run:** enter ``; + auto-detection may be empty by design. Validate, bake to + `$LinuxData/pak/acdream.pak`, verify, then install release-A client. +2. **CRUD:** add/edit/remove a temporary server and account entirely in the + launcher, then add the real Linux-reachable ACE profile. Enter its password + only in the masked field. Restart and confirm persistence. Run + `stat -c '%a' "$LinuxConfig/launcher-profiles.json"`; the exact result must + be `600`. +3. **Probe twice:** run Refresh twice, validate both status streams in `probe` + mode with native `pwsh`, using the same pre-action watcher and exact PID, + Linux session-config path, and `$LinuxConfig/launcher-profiles.json`; confirm + ACE clears the account after each. +4. **Platform posture:** confirm GUI and GUI-select client buttons are disabled + and show the explicit Modern Runtime Slice-L message. Do not bypass this + disablement and do not claim a Linux graphical-client gate. +5. **Headless:** configure `acdream.smoke` and + `/tell , LA11-I-`, launch, observe + the tell, click Stop, and validate `headless` + expected plugin. Native Linux + sends SIGINT and must reach graceful terminal status with no process leak. +6. **Update:** switch the native fixture to B, prove update actions refuse while + a headless session is active, stop it gracefully, install B, rollback to A, + reinstall B, stage launcher B, and close normally. Confirm the relaunched + binary's B marker, preserved explicit roots/feed, cleaned pending journal, + and executable owner bits on App, Headless, Launcher, and Bake. + +Copy only redacted screenshots, validation reports, pointer JSON, hashes, and +file-mode results into `$LinuxEvidence`. Keep the Linux profile and raw session +files local. Expected time: 60–220 minutes, dominated by the real bake. + +## 7. Evidence, redaction, verdict, and cleanup + +Expected evidence tree: + +```text +logs/campaign-la-user-gate-/ + automated-preflight/report.json + automated-preflight/commands/*.log + automated-preflight/publish/{win-x64,linux-x64}/... + update-fixture/fixture-report.json + update-fixture/{A,B}/manifest.json + evidence/A-install-hashes.json + evidence/B-*.png + evidence/C-probe-{1,2}-status.validation.json + evidence/*-process.capture.json + evidence/D-*.png + D-status.validation.json + evidence/E-*.png + E-status.validation.json + evidence/F-*.png + F-status.validation.json + evidence/G-*.png + G-status.validation.json + evidence/H-*.png + H-pointer-*.json + evidence/I-*.png + I-status.validation.json + I-modes.txt + verdict.json +``` + +Before sharing evidence: + +- remove or mask account names, character names, DAT paths, hostnames other than + loopback, and server-admin identifiers from screenshots; +- never copy `launcher-profiles.json`, raw session configs/status streams, + stdout/stderr that may contain user text, or environment values; +- search the shareable evidence for the exact user-entered password and any + gate-only sentinel secret; the match count must be zero; +- retain SHA-256 and sizes so local raw artifacts remain auditable. + +No additional raw child/plugin diagnostic sink is required: `pluginLoaded`, +the strict terminal status, the observer's redacted tell evidence, and the +automated targeted-signal fixture cover the acceptance questions without +capturing credentials or arbitrary chat. + +Create `verdict.json` manually with schema version 1, exact tested HEAD, rows +A–I as `pass`, `fail`, or `notApplicable`, a short redacted note per row, and +the user's overall verdict. Row I is not applicable only when no native +Ubuntu/WSL desktop is available; it blocks Campaign LA shipping under the +current Linux requirement, so it cannot be silently omitted. + +Cleanup is graceful-first and serial: + +1. Restore `` and verify it is ordinary before closing + its session. +2. Stop every launcher session once; require a passing validator and ACE-clear + observation. If a child survives the timeout, record the gate failure and + its PID before any emergency termination. +3. Close each launcher normally, then stop only the fixture-server process + created above. Do not kill ACE as a substitute for logout evidence. +4. Leave update pointers on B or roll the **isolated** client back to A through + the UI; never edit pointers or journals by hand. +5. Remove the real account through the isolated launcher UI. After review, + delete only the explicitly recorded `$WinConfig`/`$LinuxConfig` gate roots + that held plaintext passwords, or change the test account password. Do not + recursively delete a computed, empty, canonical, home, repository, or XDG + parent path. +6. Preserve the redacted evidence and reports. The large isolated pak/payload + trees may be removed only after resolving and checking their full paths are + descendants of the recorded gate roots. + +Estimated total: 3–7 hours, primarily the two real DAT bakes and full serial +test suites. A failure stops the current row; restore/stop/collect evidence, +then diagnose before advancing. Do not mark LA11, Campaign LA, or #397 shipped +until the user accepts the complete applicable matrix. diff --git a/docs/research/2026-08-14-la7b-character-selection-runtime.md b/docs/research/2026-08-14-la7b-character-selection-runtime.md new file mode 100644 index 00000000..22a72cb8 --- /dev/null +++ b/docs/research/2026-08-14-la7b-character-selection-runtime.md @@ -0,0 +1,109 @@ +# LA7b character-selection Runtime evidence + +Date: 2026-08-14 + +This note records the retail evidence and the presentation-independent state +and flow implemented by Campaign LA slice LA7b. The retained character screen +is deliberately deferred to LA8. + +## Named-retail evidence + +The implementation was derived from +`docs/research/named-retail/acclient_2013_pseudo_c.txt` before code was +written. The controlling functions are: + +- `gmCharacterManagementUI::ResetPreviouslySelectedCharacterSlot` + (`0x004ebff0`): clears the persisted selected avatar, selected guid, list + index, and character-generation slot. +- `gmCharacterManagementUI::SelectCharacter` (`0x004ec160`): resolves the + clicked guid back to the `CharacterSet` slot and persists that exact slot. +- `gmCharacterManagementUI::UpdateButtons` (`0x004ec240`): a missing or + greyed selection disables Enter and Delete; a selected greyed character + hides Delete and shows Restore. +- `gmCharacterManagementUI::RebuildCharacterList` (`0x004ec3a0`): emits every + active `CharacterSet` entry, records the previous/slot selection, chooses + the first non-greyed entry as the ordinary fallback (the first row remains + the all-grey fallback), sorts names with `wcscmp`, and then moves greyed + rows to the tail without losing their identity. +- `gmCharacterManagementUI::MakeDeleteCharacterConfirmationDialog` + (`0x004ecca0`) and `CloseDeleteCharacterDialog` (`0x004ed4a0`): deletion is + modal, and confirmation sends the persisted selected avatar. +- `gmCharacterManagementUI::ListenToElementMessage` (`0x004ed5a0`): Restore + sends the selected guid, Enter is accepted from the button and list-row + activation, and both delete/restore open retail's wait dialog. +- `gmCharacterManagementUI::EnterGame` (`0x004ed440`): entry is allowed only + for a non-zero selected guid whose `GetGreyedOutFor(GetSlot(guid))` value is + zero. +- `CharacterSet::GetSlot` (`0x004fdf60`) and `GetGreyedOutFor` + (`0x004fdfa0`) prove that delete/enter retain the original wire slot even + after display sorting, and that any non-zero grey value is disabled. +- `CPlayerSystem::Handle_CharacterError` (`0x0055d5d0`) clears the ready and + awaiting-logon latches before forwarding the typed error. The downstream + `gmUIFlow::RecvNotice_CharacterError` (`0x0047a7c0`) bounds its real display + switch to enum values below `CHAR_ERROR_NUM_ERRORS`; the `0x19` member is a + count sentinel, not a message. + +Equivalent state pseudocode: + +```text +on roster: + remember selected guid + rows = every active CharacterSet entry, retaining original slot + fallback = first row, replaced by first non-greyed row in wire order + sort rows by ordinal/wcscmp name + stable-partition non-greyed before greyed + selected = previous guid if still present, otherwise fallback + +buttons(selected): + none or greyed -> Enter disabled, Delete disabled + greyed -> Delete hidden, Restore visible/enabled + active -> Delete visible/enabled, Restore hidden + +enter(selected): + require guid != 0 and grey == 0 + use retained wire slot through the existing EnterWorld transaction +``` + +The `CharacterList.DeletedCharacters` array is not projected into this list: +retail rebuilds from the active `CharacterSet`; pending-delete identities are +the greyed entries in that active set. No entry is filtered merely because it +is greyed. + +## Runtime ownership and flow + +`GameRuntime` owns one `LiveSessionController`, which owns one +`RuntimeCharacterSelectionState` for the exact `WorldSession` generation. +The owner contains the canonical roster, wire slots, highlight, delete +confirmation, delete/restore progress, mapped error, revision, lifecycle, and +generation. App receives only the borrowed `IRuntimeCharacterSelectionView`; +all mutation uses synchronous generation-gated typed commands. Ordered deltas +use one monotonic sequence and preserve re-entrant publication order while +isolating observer failures. + +A graphical launch with no explicit selector sets +`AwaitCharacterSelection`. Connect reports and adopts the roster, starts the +sole pre-world receive loop, and returns `AwaitingCharacterSelection` without +calling EnterWorld. A typed Enter command continues the same session and +generation through the established two-phase EnterWorld path. Explicit +graphical selectors and direct/headless callers retain the established +first-available fallback because the new flag defaults to false. + +Immediate/headless entry keeps the original blocking receive/sweep pump until +ServerReady. Only a host that actually pauses at character selection starts +the asynchronous pre-world receiver. This preserves reliable-transport +resend/NAK timing and avoids two concurrent socket readers. + +LA7a's wire contracts are routed as follows: + +- delete `0xF655`: account plus retained active slot, LoginQueue; +- restore `0xF7D9`: selected guid, ControlQueue (AD-97 remains the recorded + guid-only adaptation); +- delete ack, restore `0xF643`, refreshed `CharacterList`, and + `CharacterError 0xF659`: ordered UIQueue input. + +ACE can silently return from restore for an unknown guid. Restore is therefore +a fire-and-observe command: it installs no command gate and never waits for a +reply. A later matching response updates the same owner; a fresh roster, +entry attempt, reset, reconnect, or disposal disarms stale response +correlation. `CharacterError.NumErrors` is ignored without a revision or +delta and can never become presentation text. diff --git a/docs/research/2026-08-14-la8-character-management-ui.md b/docs/research/2026-08-14-la8-character-management-ui.md new file mode 100644 index 00000000..a769c5e0 --- /dev/null +++ b/docs/research/2026-08-14-la8-character-management-ui.md @@ -0,0 +1,120 @@ +# LA8 retained character-management UI evidence + +Date: 2026-08-14 + +This note records the retail and installed-DAT evidence for Campaign LA slice +LA8, plus the exact ownership and presentation boundary implemented by the +slice. LA7b remains the authority for pre-world Runtime and wire behavior. + +## Named-retail evidence + +The implementation was derived from +`docs/research/named-retail/acclient_2013_pseudo_c.txt` and the corresponding +`acclient.h` definition before the screen was written. + +- `DBObj::GetDIDByEnum` (`0x004153A0`) forwards to + `DBCache::GetDIDFromEnumStatic`; retail resolves the category/table mapping + before loading a LayoutDesc. +- `gmCharacterManagementUI::gmCharacterManagementUI` (`0x004EC8F0`) calls + `UIMainFramework::CreateAndAddRootElement(0x10000005, 0x1000039A)`, then + binds ListBox `0x1000039D`, Create `0x100003A0`, Enter `0x100003A2`, Delete + `0x1000039F`, and Restore `0x1000039E`. +- The verbatim header at `acclient.h:56545` declares exactly that ListBox, + those four button pointers, the selected row/guid, and four dialog contexts. + It declares no viewport, `gmCG3DView`, or preview owner. +- `RebuildCharacterList` (`0x004EC3A0`) creates each row through + `AddItemFromTemplateList`, then resizes it using signed integer division: + `max(listHeight / max(rosterCount, allowedSlots), listHeight / 10)`. Thus a + 320-pixel list with five allowed slots uses 64-pixel rows, while rosters over + ten clamp at 32 pixels. It retains character identity, displays pending + deletion in red, sorts by ordinal name, moves greyed entries to the tail, + and restores/falls back selection. LA8 preserves the already canonical LA7b + display order and identity instead of sorting an App copy. +- `SelectCharacter` (`0x004EC160`) and `UpdateButtons` (`0x004EC240`) establish + the highlight and button matrix: no or greyed selection disables Enter and + Delete; an active selection shows/enables Delete; a greyed selection hides + Delete and shows/enables Restore. +- `ListenToElementMessage` (`0x004ED5A0`) routes the list selection message, + button clicks, and row-template `0x100003A5` activation message `0x1A`. + Double-activating a row calls `EnterGame` (`0x004ED440`). +- `MakeDeleteCharacterConfirmationDialog` (`0x004ECCA0`) uses retail dialog + type 5 and compares the typed response with the localized DELETE response + case-insensitively. `MakePleaseWaitDialog` (`0x004ECED0`) and + `MakeEnteringWorldDialog` (`0x004ED090`) use the wait machinery. Error + presentation enters through `MakeErrorMessageDialog` (`0x004ECB10`). The + destructor (`0x004EC080`) closes every owned dialog context. + +The shared dialog factory switch supplies catalog roots/classes used here: +message type 3 is root `0x24` / class `0x17`; confirmation-text-input type 5 +is root `0x2C` / class `0x15`; the existing wait type 2 is root `0x31` / +class `0x19`. The message button is `0x26`. Type 5 uses field `0x2C`, accept +`0x2E`, reject `0x2F`, and result property `0x9C`. + +## Installed-DAT proof + +The permanent read-only acceptance probe is +`tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs`. Run it +with `ACDREAM_PROBE_LIVE_MOUNT=1`; it reads the ordinary +`%USERPROFILE%/Documents/Asheron's Call` DAT set unless `ACDREAM_DAT_DIR` +overrides the location. It uses production `DatCollection`, +`RetailDataIdResolver`, and `LayoutImporter`; it does not write the DATs. +When the opt-in flag or installed data is absent, discovery records an explicit +skip rather than adding a no-op pass to default suite totals. + +The installed September-2013 data proves: + +- enum category/table 5 maps `0x10000005` to concrete LayoutDesc DID + **`0x21000004`**; +- selected root `0x1000039A` is 800 x 600 with eight authored children; +- the root itself authors image media `0x06007576`; that proves a retained + layout asset, not a separate render-loop background scene; +- its ListBox template is `{ 0x21000004, 0x100003A5 }`; +- the template is a 160 x 16 `UiButton`, font `0x40000009`, with Normal, + NormalRollover, NormalPressed, Highlight, HighlightRollover, and the authored + `0xFFFFFFFF` default state; +- the authored captions are Create Character, ENTER, DELETE, and RESTORE; +- neither the selected root nor any descendant is a `UiViewport`; +- enum-table-5 dialog key 2 maps to catalog DID `0x2100003C`, containing the + type-3 and type-5 roots/children above; +- string table `0x23000002` contains DELETE, Please Wait, Entering World, and + the delete-confirmation template. The template has the PLAYER variable and + resolves it into the selected character name. + +## Ownership, composition, and lifecycle + +`RetailUiRuntime` imports the exact enum-resolved root only for a graphical +launch with no explicit character selector. Its focused binding borrows +`IRuntimeCharacterSelectionView`; every highlight, enter, delete-request, +delete-confirm, restore, and cancel action crosses the existing deferred +adapter as a generation-capturing Runtime command. App retains no gameplay +mirror. Explicit-selector graphical launches keep their existing flow, and +headless does not compose this App presentation. + +The controller instantiates the authored row template in Runtime display +order, projects red pending-delete rows and the exact button matrix, and opens +the shared retail dialogs. Delete wait survives the opcode-only acknowledgement +until the fresh roster arrives. Restore is fire-and-observe: a silent ACE +no-reply ends only when Runtime expires its correlation; retail's Please Wait +opens before the synchronous restore command and closes immediately if that +command rejects or throws. Entering-world wait opens before the existing +synchronous Enter command; error, reset, reconnect, missing/displaced adapter, +and disposal close owned contexts without re-entrant commands. A failed +transient row-template import leaves the Runtime revision unconsumed and +retries on the next frame. Initial dialog-catalog, character root, and string +misses likewise retry on later ticks without mounting a duplicate root or +controller. Dialog presenter/catalog failures move their contexts to an +internal retry ledger, so UI callbacks do not retain poisoned active/queued +entries and the same context can appear after resource recovery. Priority +contexts remain ahead of ordinary retries and preserve retail's nested +preemption order when creation recovers. The mount coordinator owns a detached +controller before attaching its root or running the first template-resolving +tick; any partial failure disposes that exact controller before retry, so roots +and handlers cannot accumulate. + +There is deliberately no 3D preview and no claimed character-select background +scene. The screen root remains neutral with respect to render-loop background +composition. LA11's user visual gate owns that unresolved visual choice, plus +the live local-ACE delete/restore check. Because Enter currently completes its +established ServerReady transaction synchronously, LA11 must also verify that +the entering-world wait is perceptible on the real frame path; this slice does +not introduce a second queue or lifecycle owner merely to force a paint. diff --git a/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md new file mode 100644 index 00000000..0dfe688e --- /dev/null +++ b/docs/research/2026-08-16-campaign-cc-gate-round1-findings.md @@ -0,0 +1,1166 @@ +# Campaign CC connected gate — round 1 findings (2026-08-16) + +## RE-TEST 2 (build `1.0.2-cc.k`, post-E/F/G/closeout) — findings R3-1..R3-8 + +Heritage PASSES. Remaining, with retail side-by-side screenshots: + +- **R3-1 Profession: "Coordination" wraps to two lines** on the attribute + label — retail fits one line. Likely the Batch E block-label wrap (or a + wrong/too-large font on the runtime-written `0x100002ED` labels). + Probe the authored font+rect: if the authored font fits, our font + resolution is wrong; if not, retail doesn't word-wrap captions and the + auto-wrap is the bug. +- **R3-2 Skills: "Available Skill Credits" wraps after "Available"**, + hiding behind adjacent graphics — retail is ONE line. Same family as + R3-1 (the Batch E caption-width confinement + wrap). +- **R3-3 Skills info box: the title line ("Item Enchantment (10)") + overlaps the description text** — description must start on the line + below the title. +- **R3-4 SCROLLBAR THUMB MISSING EVERYWHERE (shared mechanism):** skills + list scrollbar and summary scrollbars show track+arrows but no thumb + (retail: the red/gold diamond); the shade slider on the color disc + works but shows no indicator (retail: the small handle across the + disc). One probe: what authors the thumb (a child? state media on the + scrollbar?) and why our UiScrollbar never draws it on these chargen + scrollbars while (per OP-era gates) other scrollbars show thumbs. +- **R3-5 Appearance color wheel render targets:** (a) we tint the RING + art — retail fills the small circle INSIDE the ring (the spot); (b) + beyond-count swatches: retail SHOWS them as blocked/dark circles — we + hide them; (c) the shade indicator on the disc (see R3-4). +- **R3-6 Eyes: retail shows a graphic icon in the disc center** (the + authored eye plug art) and its swatches still show colored rings; + acdream shows an empty ring and dark swatches. Re-derive DoGradDisk's + Eyes branch + the eye swatch rendering. +- **R3-7 Summary scrollbar thumbs** — R3-4 family. +- **R3-8 `[ Name ]` (user re-asserts, third time):** P0x17 and state + text are probed-absent; retail code writes none. UNCHECKED: the + field-widget-specific properties — dump EVERY authored property id + + value on `0x10000402` raw, and check `UIElement_TextInput::OnSetAttribute`'s + full case list for prompt/default-text properties beyond 0x17. If that + is also empty, STOP and request a live retail screenshot of the field + before any further work. + +**RE-TEST 2 fix batch (2026-08-16, R3-1..R3-8) is CODE-COMPLETE, pending the +user's visual gate.** All eight findings investigated and fixed except R3-8 +(genuinely no dat-authored placeholder mechanism exists — see its own +disposition below). App suite live-DAT env 5358/3 → 5372/3 (+14, zero +regressions); Runtime 1735/0 unchanged (untouched this round); full +solution 14578 tests / 4 skips / 0 failures (0 Core/Content changes this +round, so those suites are unaffected by construction, not merely by +measurement). No client launches. + +- **R3-1/R3-2 FIXED.** Root cause: Batch E's `UiButton.DrawBlockLabel`/ + `WrapBlockLines` auto-wrapped ANY caption that didn't fit its box width — + live-DAT-probed, this is the WRONG mechanism. `UIElement_Text:: + CalcJustification @0x00467260` (shared by the horizontal/vertical + branches) shows retail's real per-glyph break decision — BOTH the + width-triggered wrap AND the explicit-newline break — sits behind ONE + gate keyed on the `OneLine` flag passed into `GlyphList::Recalculate`; + nothing in the decomp confines a caption's wrap width to a SIBLING + element's rect (the ValueBox confinement Batch E added for the + coexisting-value-label shape). Live-DAT evidence: the Coordination + attribute-slider label (`0x100002ed`) authors `OneLine=true` (so it + should never wrap, regardless of width — decomp-confirmed, not just + measurement); the Skills credits button's "Available Skill Credits" + caption measures 193px against its OWN full 231px button width (fits + comfortably) — the 113px confined width Batch E fed into the wrap + decision was never a real retail quantity. Fixed by making + `UiButton.WrapBlockLines` split ONLY on the explicit (already- + normalized) `\n` — never width-based — a strict superset of the + pre-Batch-E single-line draw for every already-correct caption, and the + exact "Attribute\n Credits" authored-break shape still works unchanged. + The `ValueBox` confinement computation itself STAYS in + `UiButton.OnDraw` (still feeds the Center-alignment tx formula and the + now-unreachable-for-wrap clip rect for the rare multi-line+ValueBox + case) — deliberately not deleted, since it's harmless now that nothing + reads it for the wrap decision, and removing it would be unrelated + scope. **Known residual risk, NOT re-solved here:** the original + Batch E root-cause diagnosis for R2-2/R2-3 ("24dits"/"Credit0Credits") + was that the caption's own unconfined single-line render visually + overlapped the coexisting value label's rect (live-DAT-measured: the + Skills credits caption's rendered span reaches x≈196, the value box + starts at local x=116). Removing the WRAP does not reintroduce a + confinement CLIP either (deliberately — see `DrawBlockLabel`'s own doc + for why inventing a new clip boundary here would be exactly the + guessing this project forbids), so this specific button's caption and + value MAY visually overlap again in the live client. This was NOT + something the current re-test (R3-2) flagged as broken — it only + reported the wrap — so no action was taken beyond documenting the risk + for the user's own re-check. + Files: `src/AcDream.App/UI/UiButton.cs` (`DrawBlockLabel`, + `WrapBlockLines`, `OnDraw`'s Label block). Tests: `UiButtonTests.cs` + (`WrapBlockLines_LongSingleParagraph_NeverWordWraps` replaces the retired + Batch E word-wrap expectation; new + `WrapBlockLines_SingleWordNarrowerThanBoxButWiderThanOffsetAdjustedWidth_StaysOneLine`); + 2 new live-DAT pins in `CharacterCreationLiveDatTests.cs` + (`CoordinationAttributeLabel_AuthorsOneLineTrue`, + `SkillsCreditsButton_CaptionFitsFullWidth_ValueChildStartsAtMidpoint`). + +- **R3-3 FIXED.** Root cause: the title (`0x100003fb`, Y=435 H=100) and + description (`0x100003fc`, Y=460 H=100) panes' own AUTHORED boxes + overlap by 75px, live-DAT-measured — retail relies on VERTICAL + JUSTIFICATION, not disjoint rects, to keep them visually separate. + Neither pane authors dat property `0x15` (live-DAT-confirmed absent on + both), so both fall to this port's shared unauthored-VJustify default — + currently `Center`. Byte-tracing retail's real ctor default + (`UIElement_Text::UIElement_Text @0x004685ff`, + `m_eVerticalJustification = 4`) against `UIElement_Text:: + CalcJustification @0x00467260`'s ACTUAL enum semantics (`ecx_5==1` → + Center; `ecx_5==3||5` → the far edge/Bottom; anything else, INCLUDING + the ctor's own default of 4 → the near edge/Top) shows the correct + unauthored default is **Top, not Center** — a genuine, client-wide + enum-mapping bug in this port (`ElementReader.cs`'s import-time switch, + `DatWidgetFactory.cs`'s build-time switch, and `ElementInfo.VJustify`'s + field default all currently resolve an absent `0x15` to Center). Under + the CORRECT Top default both panes render near their own box's TOP edge + (25px apart — no collision); under the current Center default both + cluster toward the middle of their overlapping boxes (collision). + **Scoped fix, not the systemic one:** `CharacterCreationSkillsPage`'s + constructor now force-sets `VerticalJustify = VJustify.Top` on both + panes directly, rather than fixing the shared mapping/default. The + shared bug is CLIENT-WIDE (every DAT-imported `UiText` reaching the + `Centered`/`RightAligned`/`OneLine` static paths or the multi-line + honored-justification path) and could regress already-shipped, visually + -verified, FROZEN surfaces (vitals numbers, chat, main game UI, Options + panel) that may rely on the CURRENT Center default — fixing it properly + needs its own dedicated investigation + full regression sweep, filed as + **ISSUES.md #410** and register **AD-104**. + Files: `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` + (constructor). Tests: new + `CharacterCreationUiControllerTests.SkillsPage_InfoBoxPanes_ForceTopVerticalJustify_ToAvoidTitleDescriptionOverlap` + (fixture); new live-DAT + `CharacterCreationLiveDatTests.SkillsInfoBoxTitleAndDescription_AuthoredBoxesOverlap` + (pins the overlap premise itself, so a future DAT re-extract that makes + the boxes genuinely disjoint is visible). + +- **R3-4/R3-7 FIXED (single shared mechanism, confirmed).** Root cause: + retail authors TWO DISTINCT `UIElement_Scrollbar` thumb shapes. + `DatWidgetFactory.BuildScrollbar`'s existing vertical-thumb detection was + built against chat's own scrollbar (`0x10000012`) — a 3-slice composite + where the thumb CHILD carries no media of its own and three Type-3 + grandchildren supply the top-cap/middle/bottom-cap sprites. The chargen + Skills listbox scrollbar (`0x100003f8`), Summary's OVERVIEW listbox + scrollbar (`0x10000401`), the Summary how-to box's scrollbar + (`0x100002e7`), AND the shade slider (`0x10000321`) all instead author a + SIMPLE single-sprite thumb: the same structural child (Type 1, id 1, not + the inc/dec button) carries its OWN direct Normal/Normal_rollover/ + Normal_pressed (or, for the shade slider, a single DirectState) media + and has ZERO children — the 3-slice-only search found nothing for this + shape, so every `Thumb*Sprite` stayed 0 regardless of overflow. Fixed by + falling back to the thumb's own `DefaultImage` when the slice search + finds nothing — additive; a thumb WITH real slice children (chat) is + unaffected. This ONE fix covers R3-4's three listbox thumbs AND R3-7 + AND, as a natural consequence (same code path, same structural shape), + the shade-slider indicator half of R3-5(c) — no separate fix was needed + for the shade slider. + **Process note:** the ORIGINAL live-DAT probe for this investigation + mis-reported the shade slider's own thumb child (and, separately, the + swatch/gradCircle elements investigated for R3-5/R3-6) as authoring + "zero media" — a `string.Join(",", StateMedia.Keys)` display artifact + (a single `""` DirectState key joins to an EMPTY STRING, indistinguishable + from zero entries in a printed diagnostic — not a code defect, a + diagnostic-only mistake caught and corrected mid-investigation by + re-probing with an exact `.Count`/dictionary-content check instead of a + joined string). + Files: `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` + (`BuildScrollbar`). Tests: new `DatWidgetFactoryTests.cs` + (`Type11_VerticalScrollbar_SingleSpriteThumbWithNoSliceChildren_SetsThumbSprite`, + `Type11_VerticalScrollbar_ThumbWithSliceChildren_StillUsesSliceMedia` + negative companion); 3 new live-DAT pins in + `CharacterCreationLiveDatTests.cs` + (`SkillsListboxScrollbar_SingleSpriteThumbShape_BuildsWithNonZeroThumbSprite`, + `ShadeSlider_ThumbAuthorsItsOwnDirectStateSprite_BuildsWithNonZeroThumbSprite`); + existing `ChatFixture_ScrollbarImportsInheritedMediaRoles` (3-slice shape) + re-verified unaffected. + +- **R3-5/R3-6 CODE-COMPLETE.** Root cause, re-derived from + `gmCGAppearancePage::DoColorSpots @0x0047d850` and `DoGradDisk + @0x0047da90`: retail does NOT multiply-tint the swatch/grad-circle's + authored sprite. It builds a FRESH composited surface once + (`CreateLocalSurface` + `Blit`), then calls `SurfaceWindow::ReplaceColor` + against old-color `RGBAColor(0,0,0,1)` (opaque black — the spot + template's own placeholder fill, live-DAT-PIXEL-confirmed: the + 37x44 "spot" resource has a genuine solid-black CENTER region and a + genuine non-black RING/border region) — swapping every EXACT opaque-black + pixel for the swatch's real color while leaving the ring untouched. A + multiply-tint (Batch G's mechanism) is architecturally wrong here: black + multiplied by ANY color stays black (never recolors the center at all), + and multiplying the ring's own non-black pixels shifts their hue, + corrupting them — exactly the reported "we tint the ring" symptom. + Beyond-count swatches (R3-5b) use a COMPLETELY DIFFERENT authored + resource (enum `0x1000000f`, "blank" — live-DAT-pixel-confirmed almost + no black pixels at all, i.e. genuinely different art, not "the spot with + its center left un-recolored") shown UNTINTED — and retail's own + `pColor->SetVisible(1)` is UNCONDITIONAL for all 9 swatches (never + hidden, only the CONTENT differs). For Eyes (R3-6), `DoGradDisk`'s Eyes + branch (`arg2=1`) blits the "grad plug" icon (enum `0x10000010`) + UNTINTED (`Blit_Normal`, no color argument at all) — and, re-reading + `SetSelection @0x0047e260`'s own Eyes/non-Eyes tail + (`@0x0047e859-0047e878`), NEITHER branch ever calls + `m_pGradCircle->SetVisible` — only `DoGradDisk` (swaps the source image) + and `m_pShadeScroll->SetVisible` (a DIFFERENT element) are touched, so + the disc itself is NEVER hidden for Eyes — a correction to this port's + prior `_gradCircle.Visible = !isEyes` line (and its own now-renamed + test). + **Mechanism ported faithfully** via a new `ChargenColorSpotComposer` + (CPU-side decode-once + per-color bake-and-cache-once, uploaded through + the existing `TextureCache.UploadRgba8` seam — the SAME "decode, + recolor by exact-match, upload, cache" shape `AcDream.App.UI. + IconComposer.GetSpellComponentIcon` already established for item icons, + just matching black instead of white) and a new opt-in + `UiButton.ColorKeyFaceResolver`/reuse of the EXISTING + `UiDatElement.RuntimeImageTexture` seam — both additive, zero behavior + change for any element that doesn't set them. `Tint` itself is + UNCHANGED in meaning (still the "this swatch's color is X" signal every + existing test reads) for the swatches; the grad circle's own Tint STAYS + a genuine multiply for the non-Eyes case, matching retail's OWN + `Blit_Multiply` there (the ONE place a multiply tint is actually + correct). Wired as a fourth late-bound composition seam + (`SwatchTextureSource`), same pattern and same site as the existing + three color-computation seams (`PalSetSource`/`ClothingTableSource`/ + `PaletteColorSource`), constructed in `LivePresentationComposition.cs` + once `TextureCache` exists. + **STOPPED item, same shape as Batch G's own two STOPPED items:** this + is CODE-COMPLETE and unit/live-DAT-tested (including a real pixel-level + proof that the spot template genuinely has a black center + non-black + ring, and that the blank template genuinely doesn't), but has NOT been + visually verified in the live client this round (no client launches per + this batch's own constraint) — the user's connected gate is owed. + Files: `src/AcDream.App/UI/Layout/ChargenColorSpotComposer.cs` (new), + `src/AcDream.App/UI/UiButton.cs` (`ColorKeyFaceResolver`, `OnDraw`), + `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` + (`SwatchTextureSource`, `BuildSwatchTextureResolver`, the swatch/ + gradCircle refresh block), `src/AcDream.App/UI/Layout/ + CharacterCreationUiController.cs` + `src/AcDream.App/UI/ + RetailUiRuntime.cs` (pass-through seam), `src/AcDream.App/Composition/ + LivePresentationComposition.cs` (composition-root wiring). Tests: new + `ChargenColorSpotComposerTests.cs` (3 pure byte-level tests for the + exact-match recolor), 4 live-DAT tests in `CharacterCreationLiveDatTests.cs` + (`ColorSpotAndGradDiskResources_ResolveToExpectedDimensions`, + `SpotTemplate_HasBlackCenterAndNonBlackRing_BlankTemplateHasNeitherBlack`), + 8 `CharacterCreationAppearancePageSwatchColorTests.cs` assertions updated + (`Visible` now always true; `EyesPart_GradientDiscStaysHiddenAndUntinted` + renamed+rewritten to `EyesPart_GradientDiscStaysVisibleButUntinted_ShowsPlugIconInstead`). + +- **R3-8 DISPOSITION: genuinely nothing authored — no code change, per + this batch's own contract ("DO NOT invent a placeholder").** Dumped + EVERY property present on `0x10000402` (not just P0x17) across every + state, cross-referenced against `UIElement_Text::OnSetAttribute`'s + COMPLETE case list (there is no `UIElement_TextInput` class in retail — + the name field is a plain `UIElement_Text`/`m_filter`-bearing field, + `DynamicCast(0xc)`-confirmed in `gmCGSummaryPage::InitializePage`; the + task's own reference to that class name doesn't exist in the named + decomp). The full recognized-property space (every id `0x14`-`0x29` + plus the sparse high ids `0xC7`/`0xCB`/`0xCC` for + TruncateTextToFit/LoseFocusOnEscape/LoseFocusOnAcceptInput) has NO + mechanism for a placeholder/prompt string independent of the committed + P0x17 caption. The BaseElement/prototype-inheritance hypothesis is ALSO + ruled out — not by assumption, but because the EXISTING regression test + (`SummaryNameField_AuthorsNoP0x17OnAnyState`) already probes the FULLY + MERGED `ElementInfo` (post `LayoutImporter`'s BaseElement resolution, + confirmed by reading `ElementReader.Merge`/`UiStateInfo.Merge`'s own + "derived overrides, else inherit base" property-bag semantics) and finds + no P0x17 anywhere. The live dump found exactly ONE StringInfo-kind + property on the whole element: **`0x49`, resolving to "Your name can be + 32 characters long and cannot contain numbers or symbols."** — but + `0x49` is part of the SAME five-property tooltip family ISSUES.md + #409/GF-16 already documents client-wide (`0x47` tooltip behavior + enum = `0x10000487`, `0x48` the tooltip popup LayoutDesc DID = + `0x21000041` — the EXACT DID #409 cites, `0x49` the tooltip TEXT, + `0x4B` tooltip-enabled = true) — a HOVER TOOLTIP describing naming + rules, not an in-field placeholder, and #409's tooltip system is + unshipped so this text is authored but never shown anywhere yet. The + field's 8 children are the SAME gold-frame family (`0x100002DE-E3`/ + `0x100000E8`/`0xEA`) GF-12 already renders — reinforcing GF-15's + existing hypothesis that the `[ Name` the user perceives is this frame's + own bracket-style chrome around an empty box, not text content. + **Batch A's closure and Batch E's re-check both stand; this is the + THIRD independent negative result on the same question via three + different mechanisms (retail code, DAT P0x17, now the complete property + space + inheritance chain).** No further code-side avenue remains — the + lead should request a live retail screenshot of the field per the + batch's own contract before any further investigation. + Test: `CharacterCreationLiveDatTests.SummaryNameField_AuthorsNoP0x17OnAnyState` + extended with the exhaustive `0x49`-is-the-tooltip assertion (a + regression pin, not just a probe finding). + +## ROUND 1 RE-TEST (build `1.0.2-cc.i`, post-Batches B/C/D) — findings R2-1..R2-8 + +User's second visual pass with retail side-by-side screenshots (heritage +description, profession template text, "Attribute\n Credits" overlap, +skills credits overlap, retail skill-info box, retail Skills page, the +acdream GradCircle vs retail's color wheel). + +- **R2-1 (COMMON, regression from Batch C): description-box text + misaligned LEFT, clipping outside the frame** on Heritage, Profession, + Appearance, Town, Summary — first characters cut ("rained Starting + Skills", "OW HUNTERS", "ump, Loyalty"). Pre-Batch-C (cc.e screenshots) + the text started INSIDE the box. One shared cause suspected: the + rich-text/un-consume changes moved the text draw origin to the + element's outer rect where retail insets to an interior text region + (authored margins or interior-relative origin). PIN IT with a probe + before fixing. — **FIXED (Batch E), see below.** +- **R2-2: `Attribute\n Credits` renders the LITERAL `\n`** (UiButton + captions never escape-normalize — only BuildText does), AND the value + ("24") overlaps the caption text — the ValueLabel is not drawing in + its authored child rect. — **FIXED (Batch E), see below.** +- **R2-3: Skills "Available Skill Credits" value overlaps mid-caption** + ("Available Skill Credit0Credits") — same ValueLabel-rect family as + R2-2. — **FIXED (Batch E), see below.** +- **R2-4: Skills page functional gaps (retail screenshots 5-6):** + (a) rows are NOT selectable — retail selection turns the row brighter + white AND writes the skill's info into the lower-left description box + ("Loyalty (5) / Affects experience given to your patron... / Training + Bonus +5 / Formula :") — the info panes `0x100003FB/FC` are still + never written (investigation root-1c, missed by Batch C's scope); + (b) NOT divided into the four retail buckets (Specialized / Trained / + Useable Untrained / Unuseable Untrained with headers) — the user's + gate OVERTURNS AP-213's remaining flat-list half: implement the + buckets; (c) the skill list's scrollbar is missing. — OUT OF SCOPE for + Batch E (functional gap, not text layout); still open. +- **R2-5: the color wheel renders as static authored art** (mirror-like + disc) where retail shows the gradient wheel + gold swatch dots that + CHANGE per selected part — the user's gate PROMOTES AP-216/AP-217's + remaining halves (real palette-color swatch rendering + gradient tint) + from partial-closed to must-port. **CODE-COMPLETE at Batch G (2026-08-16), + register AP-216/AP-217 rewritten (not retired — see their own rows):** + the retail mechanism (`gmCGAppearancePage::DoColorSpots @0x0047d850` / + `SetSelection @0x0047e260` / `DoGradDisk @0x0047da90`) is fully re-derived + and ported — a new pure Core resolver + (`AcDream.Core.CharGen.ChargenSwatchColorResolver`) computes each of the + nine swatches' representative RGB (PalSet-averaged for Hair/Nose+Mouth+ + Skin/Headgear/Shirt/Trousers/Footwear at retail's own fixed sample + indices `0xd0`/`0xb0`/`0x520`, direct-Palette for Eyes at `0x103`) backed + by a new `ChargenAppearanceCatalog.TryGetColor` reading real Palette dat + objects, pinned against the installed EoR dat + (`ChargenAppearanceCatalogColorTests` — e.g. Aluvian male's shared skin + PalSet measures a plausible flesh-tone RGB(182,148,118)). + `CharacterCreationAppearancePage` recomputes all nine swatches + the + gradient disc's tint on every refresh (part change / color change / + heritage change, `CharacterCreationAppearancePageSwatchColorTests`), and + paints them through a new `ChargenSwatchColorTile` overlay element. + **Two STOPPED items block this from being visually live**, both outside + Batch G's file contract: (1) the new `PalSetSource`/`ClothingTableSource`/ + `PaletteColorSource` late-bound seams (mirroring the existing + `PreviewControl` pattern) are never assigned by the composition root + (`CharacterCreationUiController.cs`) — until wired, the mechanism stays + fully inert, matching PRE-Batch-G behavior exactly; (2) the rendering + primitive is a flat-color-fill approximation of retail's actual + recolored-sprite blit — neither `UiButton` (sealed) nor `UiDatElement` + exposes a per-instance sprite `Tint`, though the retained-UI sprite + pipeline's `DrawSprite` already carries the `Vector4 tint` multiply + retail's own `Blit_Multiply` needs; adding that property is a small, + precisely-specified addition to those two shared widget files for the + lead to sequence. Nose/Mouth/Skin (retail's own non-interactive single + representative swatch, `SetSelection`'s hard-coded `var_1e0 = 1`) is ALSO + ported, beyond AP-216/AP-217's original six-part scope. Tests: 11 new + Core (`ChargenSwatchColorResolverTests`), 6 new Content live-DAT + (`ChargenAppearanceCatalogColorTests`), 8 new App-layer fixture + (`CharacterCreationAppearancePageSwatchColorTests`) — App suite + 5321/3 -> 5329/3, Runtime 1735/0 unchanged, zero regressions. +- **R2-6: Town description text misaligned** — R2-1 family. — **FIXED + (Batch E)**, same shared mechanism as R2-1. +- **R2-7: Summary — (a) text misaligned (R2-1); (b) the summary OVERVIEW + listbox is missing its scrollbar; (c) the how-to box's scrollbar + renders but OVERLAPS the text area and has no thumb ("slider").** — + **FIXED (Batch E), see below** — (a) via the shared R2-1 mechanism; + (b) the listbox's own `0x72` scrollbar linkage was simply never wired + (every other `UiTemplateListBox` owner in the codebase already does + this — this page was the one holdout); (c) traced to a DOWNSTREAM + symptom of R2-1, not an independent bug — see the Batch E write-up for + the full geometric argument. +- **R2-8: the name field should show `[ Name ]`** — the user re-asserts + retail's prefill. Batch A verified retail's CODE never writes it; the + UNCHECKED hypothesis is the field's AUTHORED initial text (`P0x17` on + `0x10000402`) — probe the DAT; if authored, render authored initial + text (display-only; the committed state name stays empty, retail's + NameInputFilter forbids `[` so it can never be committed as a name). — + **RE-CHECKED (Batch E): NOT authored** — see below. Batch A's closure + stands; no code change. + +**Batch E (gate round 1, text layout correctness) is CODE-COMPLETE +2026-08-16, pending the user's visual gate.** R2-1/R2-2/R2-3/R2-6/R2-7 are +fixed at the mechanism level (no per-page nudges); R2-8 was re-checked and +confirmed NOT a code change. R2-4/R2-5 are explicitly out of scope +(functional gaps, not text layout) and remain open for a later round. + +- **R2-1/R2-6 root cause CONFIRMED, not the un-consume change itself:** + live-DAT-probed against the installed EoR dat, the Heritage/Profession/ + Town/Summary description boxes (`0x100003C4`/`0x100003E0`/`0x10000409`/ + `0x10000404`) all author retail's four independent text-inset margins + (dat properties `0x23`/`0x24`/`0x25`/`0x26` — `UIElement_Text:: + OnSetAttribute @0x0046a640` cases `0xf`-`0x12`, i.e. + `BaseProperty::GetPropertyName(arg2) - 0x14`, writing `m_margL`/ + `m_margR`/`m_margU`/`m_margD`): `margL=9, margR=26, margU=15, margD=15` + on every one of the four boxes (one shared authored template). This + codebase never read those four properties AT ALL, before OR after Batch + C — `UiText.Padding` (the only inset this port had) always defaults to + 0 for DAT-built text, so every box's text drew flush against x=0 + regardless of batch. The regression's actual TRIGGER was Batch C + un-consuming the gold-frame children (previously silently dropped): the + frame's own left border piece (`0x100002DE`/`0x100000E8`, live-DAT- + measured ~0-34px wide) now draws on top of/around the SAME x=0 origin + text has ALWAYS used, making the pre-existing missing-margin bug visible + for the first time. Fixed by adding the four margin properties end to + end: `ElementInfo.MarginLeft/Right/Top/Bottom` (read in + `ElementReader.ApplyCanonicalLegacyProjection`, propagated in `Merge` + with the same "non-zero derived wins" convention as `FontDid`), new + `UiText.MarginLeft/Right/Top/Bottom` properties (additive with the + pre-existing `Padding`, seeded by `DatWidgetFactory.BuildText`), and a + new pure `UiText.ContentOffsetX` static (mirrors `ContentBaseY`/ + `VOffset`'s own shape) consumed by the multi-line scrollable draw path's + per-line horizontal placement. `DatRichText.Compose`'s and + `DatWidgetFactory.BuildText`'s own authored-multiline wrap-width + formulas both shrink by the same `Padding+MarginLeft`/ + `Padding+MarginRight` inset — the wrap half of the regression (text + also overflowing the visible RIGHT edge, not just clipping on the left). + Deliberately scoped to the multi-line (non-`OneLine`) path only — the + static Centered/RightAligned/OneLine single-line branches keep their + pre-fix bare-`Padding` math, since every currently-broken box is + multi-line and touching those paths too would widen this fix's blast + radius with no known-broken target. R2-1's finding also named + "Appearance" — no Appearance-page description box exists in this + codebase (only Heritage/Profession/Town/Summary call + `DatRichText.Compose`); read as either a recollection slip or referring + to a page that will inherit this same fix automatically once/if it ever + grows one, since the fix lives in the shared `UiText`/`DatRichText` + mechanism, not per-page code. +- **R2-2/R2-3 root cause CONFIRMED, two stacked gaps:** (1) `UiButton` + captions never escape-normalized the DAT's literal two-character `\n` + escape — only `DatWidgetFactory.BuildText`'s own authored-string path + did. Centralized the normalize into the ONE choke point every P0x17 + caption resolution in `DatWidgetFactory.cs` already shares + (`ResolveAuthoredString`, plus a `NormalizeEscapes` helper for the + per-STATE caption loop that resolves a state's own `0x17` directly) — + every caller (`BuildText`, `BuildButton`'s own caption AND its lifted- + child caption, `BuildButton`'s coexisting `ValueLabel`, `BuildCheckbox`, + the per-state caption swap) now normalizes identically, closing the + exact "some callers normalize, some don't" class of bug that caused + this regression in the first place. (2) `UiButton.Label` only ever drew + ONE line, unconditionally — but retail's `UIElement_Button` IS a + `UIElement_Text` (`struct UIElement_Button : UIElement_Text`, + `acclient.h`) and these captions author `OneLine=false` + (live-DAT-probe-confirmed on `0x100003e2-e5`/`0x100003f9`), so a + caption that carries a newline OR simply doesn't fit its available + width should lay out as multiple stacked lines, the same word-wrap + every other Type-12 text box already gets (`UiText.WrapWords`). Added + `UiButton.DrawBlockLabel`/the pure, unit-tested `UiButton.WrapBlockLines` + extraction. The VALUE-overlap half specifically (R2-2's "24dits", R2-3's + "Credit0Credits"): `ValueBox` itself was NEVER null/wrong — live-DAT- + measured, both buttons' value children (`0x100002F1`/`0x100002F3` + family) resolve correctly. The overlap was the CAPTION drawing + unconfined across the button's FULL width (`Available Skill Credits` + measures 193px in the Skills button's 231px-wide box whose value box + starts at local x=116 — the caption's own unwrapped single-line render + reached x≈196, well past the value's territory). Fixed by confining the + caption's OWN drawable width to stop before `ValueBox.X` whenever a + `ValueLabel` coexists (`LabelBox`/`ValueBox` are mutually exclusive by + construction, so this never fights GF-11c's own `LabelBox` confinement). + A single-line caption that already fits draws with byte-identical + geometry to the pre-fix math — the fix is a strict superset for every + already-correct button caption in the client. +- **R2-7a root cause CONFIRMED — pure wiring gap, same shape as every + other holdout in this codebase:** the Summary OVERVIEW listbox + (`0x10000400`) authors a linked scrollbar via dat property `0x72` + (live-DAT-probe-confirmed `ScrollbarElementId=0x10000401`, a SIBLING + element, not a descendant of the listbox). Every OTHER + `UiTemplateListBox` owner in this codebase (`SocialFriendsPageController`, + `ConfigOptionsPageController`, the Fellowship/Allegiance/Squelch pages) + already resolves `ScrollbarElementId` against its page root and wires + `.Model = listBox.Scroll` — `CharacterCreationSummaryPage`'s + constructor was the one holdout that only ever wired the HOW-TO box's + own scrollbar (Batch C Commit 3) and never resolved this one. Fixed by + adding the identical resolve-and-wire block to the constructor. +- **R2-7b root cause CONFIRMED as a DOWNSTREAM SYMPTOM of R2-1, not an + independent defect** — investigated, not assumed: `UiScrollbar`'s own + draw path only paints the thumb `if (m.HasOverflow)` + (`ContentHeight > ViewHeight` on the linked `UiScrollable`). Before the + R2-1 fix, the how-to box's wrap width used the box's raw, un-inset + Width (247px) instead of the authored margin-inset content width + (247-9-26=212px) — a WIDER wrap width produces FEWER/SHORTER lines, + which can leave `ContentHeight <= ViewHeight` (no overflow → the thumb + legitimately has nothing to gate on and correctly draws nothing). Pinned + directly against the real installed strings/font (Aluvian's how-to + text, the longest composed variant — `SummaryHowTo` + the male name- + suggestion list + `SummaryHowToEnd` — at the box's real font, + `0x40000009`): composed with the CORRECT margin-inset width, the + content (multiple lines × the font's line height) exceeds the + margin-inset view height, so `HasOverflow` is true and the thumb draws. + No `UiScrollbar` code changed — this is a full explanation, not a + guess: the "overlapping the text area" half of R2-7b's report likely + reflects a genuine but minor (~7-9px) crowding between the scrollbar's + own anchor-reflowed position (`UiLayoutPolicy`, retail's raw-edge + system — verified this reflow mechanism itself works correctly, both + via `UiElement.ApplyAnchor`'s per-frame call and hand-computed against + the box's real 100x100 design-time template) and the box's authored + 26px right margin; this is within the authored geometry's own + tolerance and was NOT changed, since inventing a new pixel offset here + would be exactly the guessing this project's workflow forbids. Flagged + for the user's own re-check once the thumb is visible — it may no + longer be perceptible/relevant now that the box's own interior boundary + has moved too. +- **R2-8 RE-CHECKED, CONFIRMED NOT AUTHORED — Batch A's closure stands.** + Probed the installed EoR dat directly for `0x10000402`'s own `P0x17` + property (the SAME authored-caption mechanism `DatWidgetFactory` + already reads for every other element): absent on the default state + AND on every one of the field's named states. Batch A's GF-15 closure + already byte-verified retail's CODE never writes the prefill + (`CharGenState::RandomizeCharacter`, `gmCGSummaryPage::InitializePage`); + this batch closes the remaining unchecked half (the DAT-authored- + initial-text hypothesis) the same way — negative. No code change; + pinned as a live-DAT regression test + (`SummaryNameField_AuthorsNoP0x17OnAnyState`) so a future DAT re-extract + or a future guess can't silently reintroduce the wrong fix shape. + +Fixture + live-DAT tests only this round (no graphical client launch). +App suite 5334/3 (was 5321/3, +13, zero regressions): +1 `DatRichText` +wrap-width-with-margins test, +3 `UiText.ContentOffsetX` tests, +5 +`UiButton`/`DatWidgetFactory` tests (`WrapBlockLines` × 3, the value-box +confinement shape, the escape-normalize regression), +4 live-DAT tests +(the Heritage margin/first-line-origin pin, the Summary listbox scrollbar +wiring, the Aluvian how-to overflow proof, the name-field no-P0x17 pin). +Runtime 1735/0 unchanged. Full solution Release build green (0 errors). +Blast radius swept: `UiText.MarginLeft/Right/Top/Bottom` default to 0 and +are ADDITIVE with the pre-existing `Padding`, so every DAT-imported +multi-line text box that does NOT author properties `0x23`-`0x26` (the +overwhelming majority client-wide, including chat and the main game UI) +is byte-identical to before this fix — confirmed by the unchanged full +App suite pass count outside this batch's own new tests. + +**MILESTONE (2026-08-16, post-Batch-A build `1.0.2-cc.g`): the user +completed the FIRST LIVE CHARACTER CREATE from acdream against local ACE — +launcher → character select → Create → six pages → name → Finish → real +character created. USER-CONFIRMED: "Yes i could now create a char." The +create flow's core path is live; the round continues for visual parity +(Batches B-D) and the remaining script checks (rejection dialogs, +log-straight-in confirmation, credit/randomize/exit warnings).** + +**Batch B (selection state media + label state) is CODE-COMPLETE +2026-08-16, pending the user's visual gate.** GF-1, GF-8, GF-9, GF-10, +GF-11b, and GF-11c are fixed — see each entry's own FIXED note below. +Fixture + live-DAT tests only this round (no graphical client launch); +App suite 5282/3 (was 5266/3), Runtime 1735/0 unchanged. Register: +AP-222 RETIRED, AP-215 NARROWED (item 1 retired, item 2 stays open). + +**Batch C (text/frame/label fidelity, the largest visual batch) is +CODE-COMPLETE 2026-08-16, pending the user's visual gate.** GF-2, GF-3, +GF-4, GF-6, GF-11a, GF-12, and GF-14's text half are fixed — see each +entry's own FIXED note below. Three commits: (1) chargen-scoped rich +text + labels + backdrops (new shared `DatRichText` composer; `UiButton` +gains a coexisting `ValueLabel` slot; Heritage/Profession backdrop +`SetState` cascades; AP-216/AP-217 partially closed — the "beyond count"/ +"Eyes-blank" halves ship, the palette-to-RGB "actual color"/"gradient +tint" halves stay open, judged disproportionate to add alongside this +batch's ~10 other fixes), (2) a CLIENT-WIDE `LayoutImporter` fix +un-consuming media-bearing dat children on `UiText`/`UiField` (37 distinct +(layout, element) pairs across 15 layouts, independently re-derived — +includes MAIN GAME UI and the chat transcript, closing the build half of pre-filed +issue #366), (3) the Summary how-to text +(`gmCGSummaryPage::SetHowToText`) plus the scrollbar-to-text-scroll +linkage Commit 2 left unbound. Fixture + live-DAT tests only (no +graphical client launch); App suite 5307/3 (was 5282/3, +25 tests, one +pre-existing full-suite-only allocation flake unrelated to this batch — +passes in isolation and in the full Release run), Runtime 1735/0 +unchanged. Register: AP-215/AP-216/AP-217 rewritten, AP-218 retired, +AD-103 retired. **FLAG FOR THE LEAD: chat and the main game UI both +render new dat children (gold frames, an unseen-text indicator) for the +first time this batch — the user's own visual check of both is owed +before considering Commit 2 closed; automated coverage cannot catch a +purely visual placement regression.** + +**Batch D (chargen 3D preview backdrop) is CODE-COMPLETE 2026-08-16, pending +the user's visual gate.** GF-7 and GF-14 are fixed — see each entry's own +FIXED note below. GF-16 (client-wide tooltips) was investigated in the same +root-cause pass but is explicitly out of this batch's scope — deferred as +`docs/ISSUES.md` #409 with its own decomp anchors. Fixture + live-DAT tests +only this round (no graphical client launch); App suite went from 5307/3 to +5321/3 (+14, zero regressions), Runtime 1735/0 unchanged. Both Launcher test +projects (this being the first build of the merged tree carrying the #406 +launcher merge) pass at their own baselines: Launcher.Core.Tests 337/0, +Launcher.Tests 67/0. Blast radius: `PrivateEntityViewportRenderer` (shared +with paperdoll and creature-appraisal) gained an OPTIONAL second entity +slot reserved via a `backdropRenderId` constructor parameter — paperdoll +and creature-appraisal never pass one, so their draw stays single-entity +by construction (`SetBackdrop` throws if called without a reserved slot, +and the entity-list-assembly helper `BuildDrawEntities` degrades to +exactly the main entity whenever no backdrop is configured/set). + +**Batch F (Skills page completion — R2-4 + review F1/F2) is CODE-COMPLETE +2026-08-16, pending the user's visual gate.** Four of R2-4's five +sub-items are fixed; R2-4b (the four-bucket sorted model) is NOT — see the +AP-213 register row for the exact missing data channel this batch's +investigation pinned down (`SkillBase.MinLevel`, confirmed present in the +installed dat but not threaded through `ChargenOptions`/ +`CharacterCreationRuntimeBindings`). **Fixed:** R2-4a (row selection — a +row click, or an arrow click matching retail's own post-Increase/ +DecreaseSkillLevel re-select, highlights the row and writes the info +panes' TITLE — name + score — and a level-gated bonus line; the +description/formula halves stay unported for the SAME missing-data reason +as R2-4b, documented on `CharacterCreationSkillsPage.RefreshInfoBox`'s own +doc rather than a new register row since no file outside the page's own +scope was needed to identify it); R2-4c (the listbox's own authored +scrollbar link, live-DAT-CONFIRMED at `0x100003F8` — exactly this batch's +own "+1 from the listbox" hypothesis — wired to the listbox's `Scroll` +model, the ordinary page-level linkage every other `UiTemplateListBox` +owner uses); review F1 (the Untrained-down/Specialized-up literal `"0"` +cost text the prior port rendered blank, and the exact per-branch 999-blank +gate — up-cost only, never down-cost); review F2 (the +`pSkillUpButton`/`pSkillDownButton` Ghosted/Enabled state pair, gated on +credits and a re-derived `bUntrainable`/`bUnspecializable` — the row's own +effective cost being nonzero — using cost data the page already resolves, +no new channel needed). Fixture + one live-DAT test this round (no +graphical client launch); App suite live-DAT env went from 5321/3 to +5328/3 (+7, zero regressions — one pre-existing baseline flake, the +streaming "injected dungeon enqueue failure" test, is a known standalone- +pass-only flake unrelated to this batch and did not reproduce on the full +post-fix run), Runtime 1735/0 unchanged. + +User ran the six-page chargen flow live (build `1.0.2-cc.e`, RDP session, +windowed). Screenshots: retail Heritage, acdream Heritage, retail +Profession. The user's side-by-side retail reports are AXIOMS +(`feedback_retail_oracle_no_whack_a_mole`). Pre-page findings #405 (fixed +`344d88bf`), #406 (open), #407 (fixed `e601a496`) are recorded in +ISSUES.md; this doc is the six-page batch. + +## Functional (blocking or behavior-dead) + +- **GF-1 Heritage selection dead/unmarked — FIXED (Campaign CC gate round + 1, Batch B).** Root cause: retail authors a custom radio-selection state + pair (`RetailUiStateIds.Unselected`/`Selected`, `0x10000016`/ + `0x10000017`) on the heritage row (property-only state descriptors, no + media) with the actual art on a single stateful CHILD (the dot, + `0x100003C0`, media `0x06006E35`/`0x06006E21`, live-DAT-probe-confirmed). + `UiButton.AddAvailableStates` only recognized the standard Normal/ + Highlight/Ghosted name space, so `_availableStates` never admitted the + custom pair and `.Selected` committed nothing (probe-verified before the + fix: `Selected=true` left `ActiveState=="Unselected"`, while the raw + `TrySetRetailState(0x10000017)` already worked). Fixed by teaching + `UiButton` to detect the authored pair (`HasStateMedia("Unselected") && + HasStateMedia("Selected")`) at construction and bypass the standard + state machine for it — `.Selected` now routes directly to + `RetailUiStateIds.Selected`/`Unselected`, additive and gated on the + pair's presence, so every OTHER button's Normal/Highlight path is + byte-identical. The SAME fix also lights the Profession template icon + (`0x100003D9`), the Appearance Face/Clothes sub-tabs (GF-8, below), and + the gender buttons (whose media lives directly on the button, not a + child — the OTHER shape this fix covers). The open-roll's own + no-lit-dot-on-entry symptom shares this same root: `CharacterCreationHeritagePage.Refresh` + already sets `button.Selected = heritageId == snapshot.HeritageId` for + every row on every refresh (including the first one after open), so the + same `.Selected`-was-a-no-op bug silently ate the initial roll's own dot + too — this fix closes both halves of GF-1 with the same change. +- **GF-5 Skills page empty — FIXED (Campaign CC gate round + 1, Batch A).** Root cause was `CharacterCreationSkillsPage.RebuildRows` + resolving `Templates[0]` (retail's own 3-child bucket-HEADER row, + `0x100002F4`) instead of `Templates[1]` (the REAL skill row, + `0x100002FF`, live-DAT-probe-confirmed 7 children) and requiring the + resolved root to be a `UiButton` (it's a plain container). Byte-traced + against `gmCGSkillsPage::DoSkillRecords @0x004817e0` + + `tagSkillRecord`'s copy-constructor field order to map every child id: + name (`0x10000301`), `pSkillLevelText` (`0x10000302`), `pUpCostText` + (`0x10000303`), `pSkillUpButton` (`0x10000304`), `pSkillDownButton` + (`0x10000305`), `pDownCostText` (`0x10000306`). Fixed to resolve + `Templates[1]`, wire the real per-row up/down arrow buttons to + `ListenToElementMessage @0x004814c0`'s own plain-click dispatch + (`IncreaseSkillLevel`/`DecreaseSkillLevel`), retiring AP-213's click-to- + advance/double-click-retreat single-button substitution (narrowed, not + fully retired — the flat-list-vs-four-bucket half stays). The credits- + caption clobber (`SkillsPage.cs:81-82`, now different line numbers) is + UNCHANGED — Batch C's scope. +- **GF-9 Appearance color swatches do nothing observable — FIXED (Campaign + CC gate round 1, Batch B).** Root cause confirmed as working-but- + invisible, not a dead dispatch: the `SelectColor`/`SetAppearanceIndex` + click path was already intact end to end (unchanged by this fix). The + swatch buttons themselves author ONLY an unnamed DirectState sprite — + live-DAT-probe-confirmed NO Normal/Highlight media at all — so the + existing `swatch.Selected = ...` highlight assignment in + `RefreshColorAndShadeControls` was a permanent no-op; nothing could ever + have shown a click's effect. Retail's REAL feedback mechanism is nine + separate companion overlay elements (`0x10000318`-`0x10000320`, + `CharacterCreationAppearancePage.SwatchOverlayIds`, live-DAT-confirmed + siblings of the swatches under the color-wheel container `0x100003B9`, + index-paired 1:1 with `SwatchIds`) that retail's `SetColor @0x0047DD50` + shows/hides via `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible` — + cross-confirmed against `gmCGAppearancePage::InitializePage`'s own + swatch/overlay id-pair table (`@0x004800ff-00480164`). Fixed by wiring + exactly one overlay visible per part, tracking the current part's + selected color index; retires AP-215's swatch-selection substitution + (item 1 — the icon-vs-ordinal item 2 stays open). +- **GF-11a Town description text does not change when switching towns — + FIXED (Batch C, Commit 1).** The composed string + (`gmCGTownPage::SetTownString @0x0047c1f0`'s `howTo + "\n\n" + townText + + "\n"` — this ONE composition site is where retail's OWN code, not the + authored DAT string content, inserts the separator, confirmed via the + compiled format literal's raw bytes `u"\n\n%s\n"`) was already + byte-correct; the real gap was rendering it as a single un-wrapped line, + so the town-specific SUFFIX rendered past the clipped viewport — + switching towns changed the underlying string but not what was visibly + on screen. Fixed by routing through `DatRichText.Compose` (same fix + family as GF-2). +- **GF-13 Summary shows "-Non-admin or Non-envoy" below the name — FIXED + (Campaign CC gate round 1, Batch A) — this commit.** Root cause: dat + property `0x3B` (Invisible — `UIElement::OnSetAttribute @0x00462d80` + case 8) was never read by the importer at all; elements `0x10000403` + ("Non-Admin") and `0x10000494` ("Non-Envoy") both author it `true` + (live-DAT-probe-confirmed, path `0x100003CC > 0x100003D0 > 0x100003D6 > + {0x10000403,0x10000494}`). Blast-radius sweep found **1,083 elements + client-wide** author the same flag — a blanket importer-wide honor is + its own visual gate, filed as ISSUES.md #408. This fix is CHARGEN-SCOPED + ONLY: `ElementInfo.Invisible`/`UiElement.AuthoredInvisible` are pure + data additions (read/stored everywhere, acted on nowhere by the shared + importer path), and `CharacterCreationUiController.HideAuthoredInvisibleElements` + walks its own mounted subtree once at construction and hides whatever + the dat itself marked hidden — by the authored flag, not a hardcoded id + list. Register AP-230 records the scoped-vs-general split. +- **GF-15 Summary name entry DEAD + Finish unpressable — FIXED + (Campaign CC gate round 1, Batch A) — this commit, LIVE-VERIFIED end to + end.** The live-repro investigation (offline `ACDREAM_OPEN_CHARGEN=1` + alone does NOT open the chargen screen — `RuntimeCharacterCreationState` + only activates via `LiveSessionController.StartAsync`'s authenticated- + connect path, `LiveSessionController.cs:791`; the repro required a real + connect to the project's own local ACE test server) showed the FIRST + click into the name field correctly focuses it and typing correctly + lands characters — the modal/pick/focus mechanics the earlier static + investigation examined were never broken. The REAL cause only surfaces + after the FIRST dialog opens: pressing Finish with an empty name + successfully creates the NoName `RetailMessageDialogView` + (`visible=true`, live-DAT-probe-confirmed nonzero popup/message/button + geometry — 400x95 popup, correctly centered) but it renders NOTHING and + silently absorbs every subsequent click across the WHOLE canvas, + including clicks aimed at the name field or Finish button underneath. + Root cause: `CharacterCreationUiController.Tick()` (and + `CharacterManagementUiController.Tick()`) call `UiRoot.BringToFront(Root)` + UNCONDITIONALLY every frame while their screen is open (needed so + chargen stays above the occluded character-management screen + underneath, register AP-229); a dialog's root is a direct sibling of + those screen roots under the same `UiRoot`, and + `RetailWindowManager.BringToFront` is a simple "highest ZOrder among + siblings + 1" — whichever sibling's own `BringToFront` call runs LAST in + a frame wins. `RetailDialogFactory.Tick()` never re-asserted its own + open dialogs' z-order, so the VERY NEXT frame's screen `Tick()` (which + always runs before the dialog factory's own `Tick()` in + `RetailUiRuntime.Tick(double)`'s per-frame sequence) silently buried the + dialog behind the screen's opaque backdrop — while the dialog remained + the registered `UiRoot.Modal` and kept EXCLUSIVE input priority + (`OnMouseDown`'s Modal-vs-bounds gate is independent of render/z-order). + Fixed by having `RetailDialogFactory.Tick()` re-raise every open dialog + (in `_openOrder`, so the most recently opened stays topmost) every tick, + matching retail's real always-on-top dialog behavior. Live-verified the + COMPLETE user sequence after the fix: click name field (focuses), type + (lands), press Finish empty (NoName dialog now VISIBLY renders: "You + must enter a name for this character!"), click OK (dismisses cleanly, + `Modal` clears), click the field again (still focusable/typable). The + `[ Name` prefill question is CLOSED, not a bug: byte-verified neither + `CharGenState::RandomizeCharacter @0x005c6d80` nor + `gmCGSummaryPage::InitializePage @0x0047bbf0` ever write text into the + name field (`InitializePage` only sets the input filter) — retail's + field is genuinely code-empty on a freshly-rolled character, matching + acdream's existing (correct) behavior; the `[ Name` the user saw was + most likely the field's own bracket-style empty-state chrome (GF-2/GF-12 + textbox-decoration family), not a missing name-prefill feature. + **Re-checked at Batch E (R2-8) against the ONE hypothesis this note + left unchecked** — an authored initial-text string on the field's own + dat property `0x17`, the SAME mechanism `DatWidgetFactory` reads for + every other element's caption — and confirmed ABSENT on the field's + default state and every named state alike, live-DAT-probed against the + installed EoR dat. This closure now covers both the CODE half (this + paragraph) and the AUTHORED-DATA half (Batch E); no further hypothesis + remains unchecked. + +## Presentation families (retail parity) + +- **GF-2 Description textboxes broken everywhere — FIXED (Campaign CC gate + round 1, Batch C, Commit 1 + Commit 2).** Root cause was TWO stacked + gaps, both closed: (1) the Heritage/Town/Profession description pages + bypassed escape-normalize + word-wrap entirely, assigning a raw + unwrapped single-`Line` `LinesProvider` — fixed by routing every + description box through the new shared `DatRichText.Compose` helper + (ports `UIElement_Text::SetStringInfoWithFont`/`AppendStringInfoWithFont`'s + composition model: escape-normalize, per-segment word-wrap, per-segment + palette color — Heritage's own header/body segments now render in + retail's own green/white, matching `AppendStringInfoWithFont`'s font-index + argument). (2) The authored gold frame (8 pieces) and linked scrollbar + were silently dropped by `UiText.ConsumesDatChildren` — fixed by + Commit 2's client-wide `LayoutImporter` carve-out (see GF-12). Both + halves are pinned by live-DAT tests (`CharacterCreationLiveDatTests`, + `LayoutImporterMediaBearingChildSweepTests`) and unit tests + (`DatRichTextTests`). +- **GF-3 Profession template description textbox missing — FIXED (Batch C, + Commit 1).** `gmCGProfessionPage::InitializePage @0x00483068`'s + `m_pTextBox` (`0x100003e0`) was never bound. Fixed: + `CharacterCreationProfessionPage` now binds it and composes the + per-template string (`ID_CharGen_CustomText`/`BowText`/`SwashText`/ + `LifeText`/`WarText`/`WayText`/`SoldierText`, `UpdateProfession + @0x004821b0`'s per-case literal, plain `SetStringInfo` — one color, no + palette) through the same `DatRichText` helper. +- **GF-4 Profession labels missing — FIXED (Batch C, Commit 1).** Two + distinct mechanisms, both closed: (a) the four display buttons + (avail/health/stamina/mana credits) author their caption directly as + their own P0x17 AND carry a separate media-less Type-12 value child that + `UiButton.ConsumesDatChildren` used to drop entirely — pages substituted + the button's own `.Label`, destroying the caption. Fixed by giving + `UiButton` a coexisting `ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` + slot (`DatWidgetFactory.BuildButton`, gated on the button's own P0x17 + caption existing), so the caption and the live value now render + independently — the same fix also closes the Skills page's credits + badge. (b) The six slider name labels (`0x100002ed`, live-DAT-measured + as `UIElement_Button` — retail's `UIElement_Button` is DynamicCast(0xc)- + compatible with `UIElement_Text`) are now written once at construction + with `CharGenState::GetAttributeName @0x005C3A20`'s six hardcoded + literals, matching retail's own single `InitializePage`-time write + (never re-written on refresh, same as retail). +- **GF-6 Appearance spin captions are numbers — FIXED (Batch C, Commit 1), + retiring AP-218.** `gmCGAppearancePage::Update @0x0047e8f0` writes a + heritage-flavored STATIC caption to the Hair/Eyes/Skin spins only + (normal / `GearText_*` / `OlthoiText_*` variants) — never an index, and + never touches the other six spins' own DAT-authored caption at all. + Removed the prior 1-based-ordinal/gear-name substitution outright. + AP-215's own icon-thumbnail item (the four icon-only spins still show no + per-choice icon art — a DIFFERENT, still-open gap) is rewritten, not + retired — see that row. +- **GF-7 Preview backdrop black on Appearance (and Summary, GF-14) — FIXED + (Campaign CC gate round 1, Batch D).** Root cause: retail's + `gmCG3DView::Update @0x004EE9D0` (~0x004eecd3-0x004eed44) constructs a + SECOND `CPhysicsObj` from the current heritage's own + `HeritageGroup_CG.environmentSetupID` field (verbatim struct layout, + `acclient.h`) and adds it to the SAME viewport's `creature_mode_objects` + the player object lives in — this codebase already parsed that id as + `ChargenHeritageOptions.EnvironmentSetupId` (`ChargenTableReader.cs`) + but never consumed it anywhere. The decompiler elides the actual field + read (`var_b8`/`eax_32`, an unresolved-call artifact — see + `claude-memory/feedback_bn_decomp_field_names.md`); cross-referencing + `acclient.h`'s `HeritageGroup_CG` struct (environmentSetupID sits right + after setupID) confirmed what the elided value is. The backdrop object + gets NO explicit position/orientation/scale — `CPhysicsObj::makeObject` + (0x004eed2f) leaves it at the scene origin with identity orientation, + same as the player object's own default placement, and retail's own + `AddObject` insertion order puts the backdrop BEFORE the player (the + player's own re-`AddObject` happens later, at ~0x004ef199, after the + full clothing ObjDesc composes). Fixed by extending + `ChargenPreviewEntityBuilder` with `TryBuildBackdrop` (builds a plain, + unposed Setup mesh from the heritage's `EnvironmentSetupId`, returning + null for id 0/unset or an unresolvable Setup — matching retail's own + `if (eax_32 != INVALID_DID.id)` gate at 0x004eed29), giving + `PrivateEntityViewportRenderer` an optional second entity slot + (`SetBackdrop`, reserved via a `backdropRenderId` constructor param so + paperdoll/creature-appraisal — which never pass one — cannot acquire a + second entity even by accident), and wiring `ChargenPreviewController` + to rebuild the backdrop whenever the HERITAGE changes (narrower than the + existing camera-eye-reset gate, since `environmentSetupID` is a pure + function of heritage, never gender or appearance selection). Both + Appearance and Summary get the fix from the SAME `ChargenPreviewRenderer` + facade — no page-specific code needed, confirmed both pages call the + identical `gmCG3DView::Update` on their own separate `gmCG3DView` + instance. Lighting was independently re-verified against the same + function's `SetLight` call (`DISTANT_LIGHT, 2.0, (0.3, 1.9, 0.65)` + direction, default white color) and found to ALREADY match byte-for-byte + what CC6a shipped — no lighting change was needed. +- **GF-8 Appearance Face/Clothes sub-tab selection unmarked — FIXED + (Campaign CC gate round 1, Batch B).** Same root and same fix as GF-1: + the Face (`0x100003A9`)/Clothes (`0x100003AA`) sub-tab buttons author + the identical custom Unselected/Selected radio-pair shape (media on a + stateful icon child, `0x100002E9`, live-DAT-probe-confirmed) — not the + AP-222 family as originally suspected (AP-222 turned out to be a + DIFFERENT mechanism, the per-state label color/outline gap fixed + alongside GF-11b below). `UiButton`'s custom-selection-pair bypass + fixes both in one change. +- **GF-10 Zoom buttons show identical art — FIXED (Campaign CC gate round + 1, Batch B).** Pure wiring gap, not a widget mechanism problem — both + zoom buttons already author a standard Normal/Highlight(/rollover) pair + (live-DAT-probe-confirmed). `gmCGAppearancePage::ZoomIn @0x0047CF00` + (`@0x0047d005/0x0047d00f`) ends `ZoomInButton->SetState(6)` (Highlight), + `ZoomOutButton->SetState(1)` (Normal); `ZoomOut @0x0047D050` mirrors. + `CharacterCreationAppearancePage`'s click handlers only ever called + `PreviewControl.ZoomIn()/ZoomOut()`, never touching either button's + state — fixed to set the mutual-exclusive pair on every click. Re- + derived the INITIAL state from `InitializePage @0x0047fdd0-0048032e`: + `m_bZoomedIn = 0` is set at construction, but NO explicit initial + `SetState` call exists for either zoom button anywhere in + `InitializePage` — both start at their DAT-authored "Normal" default + until the first real zoom click; this port does not force an initial + Highlight either. +- **GF-11b Town selected marker does not turn white — FIXED (Campaign CC + gate round 1, Batch B).** The marker PIN art itself already swapped + correctly (the town button's own Normal/Highlight state machine was + never broken — its marker child, `0x1000040C`, authors real Highlight + media). What was missing: retail ALSO recolors the town NAME caption + (a lifted Type-12 child, id collides with the page-level description + panel's own id `0x10000409` in the installed dat — two distinct + elements in two distinct subtrees, harmless for the per-button lift) + from gold (218,167,85) to white (255,255,255) on selection, live-DAT- + measured. `DatWidgetFactory.BuildButton` lifted the caption's font + COLOR once at build time with no per-state override. Same root and fix + as AP-222 (below): per-state label color/outline, applied off the + REQUESTED retail state id. +- **GF-11c Town names misaligned on the map — FIXED (Campaign CC gate + round 1, Batch B).** The per-button caption's own authored rect + (`(0,4,100,37)`, Center-justified, live-DAT-measured) was being + discarded in favor of a Left-aligned offset computed from the marker + FACE's rect (`face.X + face.Width + 4`) — correct for the heritage/ + template/Face-Clothes row family (label authored DIRECTLY on the + button, beside a single-purpose face segment) but wrong here, where a + DISTINCT Type-12 caption child was lifted with its own independent + geometry. Fixed by adding `UiButton.LabelBox`: when a distinct lifted + caption carries its own rect, the label draws within THAT box using + its own authored justify instead of the face-relative offset; every + other button (`LabelBox` null) keeps the EXACT prior draw math. +- **GF-12 Missing authored gold frames around boxes — FIXED (Batch C, + Commit 2).** Root cause: `UiText.ConsumesDatChildren` (true unless a + state authors PassToChildren) and `UiField.ConsumesDatChildren` + (unconditionally true) dropped EVERY dat child at import time, + including the eight gold-frame pieces (`0x100002DE..E3`, + `0x100000E8/EA`) every description/report box authors. Fixed by a new + `LayoutImporter.BuildWidget` carve-out (mirroring the existing `UiMeter` + text-overlay carve-out): build any child with its OWN non-empty + `StateMedia`, leaving purely structural/property-only children dropped + as before. Independently re-derived blast radius: **37 distinct + (layout, element) pairs across 15 layouts** (see the commit message for + the full enumeration), including MAIN GAME UI (`0x21000005/0x1000059A`) + and the chat transcript (`0x2100006F/0x10000011` — closing the BUILD half of + pre-filed issue #366's own "fix shape" recommendation, which proposed + this EXACT carve-out). Full App suite (5304 tests): zero regressions. + **The user's own visual check of chat + the main game UI is still owed** + — automated coverage cannot catch a purely visual placement regression. + **Closeout Group 3 (F5/F6, 2026-08-16):** one of this carve-out's + media-bearing children — the chat new-text indicator, `0x1000048C`, + live-DAT-confirmed authored `Invisible=true` on every layout it appears + in (`0x21000005`/`0x21000006`/`0x2100005B`/`0x2100006F`) — was building as + a visible phantom element retail never shows. Fixed with a NARROW honor + scoped to exactly this carve-out (`LayoutImporter.BuildWidget`'s + `UiText or UiField` branch sets a built child's `Visible = false` when its + own `AuthoredInvisible` flag is set), not the general #408 client-wide + honor. Verified in both directions: the invisible chat indicator now + builds hidden, and the eight chargen/main-game-UI gold-frame pieces + (`0x100002DE-E3`/`0x100000E8`/`0x100000EA`) do NOT author `Invisible` and + stay visible — confirmed both by a live-DAT sweep + (`MediaBearingChildSweep_EnumeratesWhichAffectedChildrenAuthorInvisible`) + and a fixture regression test. +- **GF-14 Summary paperdoll backdrop black — FIXED (Campaign CC gate round + 1, Batch D, same fix as GF-7 above — both pages call the identical + `gmCG3DView::Update` on their own `gmCG3DView` instance).** **Summary + textbox wrapper + scrollbar — FIXED (Batch C, Commit 2 for the + frame/build half, Commit 3 for the scrollbar LINK and the how-to text's + own content — see the Suspected-shared-roots entry and Commit 3's own + composition of `gmCGSummaryPage::SetHowToText` into `0x10000404`).** +- **GF-16 Hover tooltips missing on all pages** (retail pops tooltips). + DEFERRED to its own gate round — filed as + [`docs/ISSUES.md` #409](../ISSUES.md) with the decomp anchors + (`UIElement::StartTooltipAtMouse @0x00460D70`, + `UIElementManager::StartTooltip @0x0045DE90` + `@0x00459700`, layout DID + `0x21000041`, properties P0x47-P0x4B, ~253 authored elements, prefs + `Misc_TooltipEnable`/`Misc_TooltipDelay`) the Batch D investigation + surfaced. Out of Batch D's scope: it is a CLIENT-WIDE mechanism, not the + chargen 3D preview backdrop Batch D actually fixed (GF-7/GF-14 above). + +## Suspected shared roots (to be CONFIRMED by the investigation, not assumed) + +1. ~~Missing frames/labels/statics across every page (GF-3, GF-4 labels, + GF-12) — one importer/mount-level gap OR retail writes them at runtime; + decide per element from the authored DAT + decomp.~~ CONFIRMED, CLOSED + at Batch C: TWO distinct mechanisms, both a single shared fix each. (a) + GF-3/GF-4's labels were runtime-composition gaps (unbound description + textbox; a value-write clobbering a caption) — fixed per-page, Commit 1. + (b) GF-12's frames were the importer-level gap the investigation + suspected: `LayoutImporter.BuildWidget`'s `ConsumesDatChildren` handling + dropped every dat child of a `UiText`/`UiField`, client-wide — fixed by + the new media-bearing-child carve-out, Commit 2. +2. ~~Rich text (escape decoding, wrap, scroll, frame) — one text-widget gap + feeding GF-2/GF-3/GF-11a/GF-14.~~ CONFIRMED, CLOSED at Batch C: the new + shared `DatRichText.Compose` helper (escape-normalize + per-segment + word-wrap + per-segment palette color, Commit 1) plus the Commit-2 frame/ + scrollbar un-consume plus Commit-3's scrollbar-to-text-scroll linkage + (`UiScrollbar.Model = text.Scroll`, the same pattern + `ChatWindowController` already used) together close the WHOLE family — + GF-2/GF-3/GF-11a/GF-14's text half are all FIXED; GF-14's backdrop half + (GF-7 family) is unrelated and stays open. +3. ~~Selection state media (GF-1 dot, GF-8 sub-tabs, GF-11b white marker, + GF-10 zoom art) — the AP-222 measured mechanism (state media authored + vs applied) across widget kinds.~~ CLOSED, split into TWO distinct + mechanisms, both fixed at Batch B: (a) GF-1/GF-8 share a genuinely + UNRECOGNIZED custom state-name pair (`UiButton` never admitted + "Unselected"/"Selected" into its available-states set at all); GF-10 + was pure wiring (the standard Normal/Highlight pair was never even + requested). (b) GF-11b turned out NOT to be a state-media gap — the + marker's own media swap already worked; the actual gap was AP-222's + real mechanism, per-state LABEL COLOR/OUTLINE (a property commit + distinct from the art/media commit, and NOT gated by the same art- + availability check `ActiveState` is). See each GF's own FIXED entry + above and the retired AP-222 / narrowed AP-215 register rows. +4. ~~Preview backdrop (GF-7/GF-14) — what gmCG3DView clears/draws.~~ + CONFIRMED, CLOSED at Batch D: `gmCG3DView::Update`'s own + `m_pbgObject`/`m_bgSetupID` pair, sourced from the heritage's + `HeritageGroup_CG.environmentSetupID` field — already parsed into this + codebase as `ChargenHeritageOptions.EnvironmentSetupId` but never + consumed before this fix. See GF-7's own FIXED entry above for the full + decomp citation. +5. ~~Input routing on Summary (GF-15) — focus/typing path on the stacked + chargen screen.~~ CLOSED: focus/typing routing was never broken (live- + verified); the real cause was `RetailDialogFactory` never re-asserting + its open dialogs' z-order against the chargen/char-management screens' + own per-tick `BringToFront` — see GF-15's own entry above. Batch A + fixed it. + +## Process + +Root-cause investigation FIRST (report-only, static + live-DAT probe +tests, NO client launches while the user's client may be running), then +batched fix rounds per family with Opus review, one republish per batch. + +## RE-TEST 3 (lead's own live captures of `1.0.2-cc.m`, 2026-08-16) — R4-1..R4-4 + +Captured by the lead driving the real client (testaccount, graceful +close). Heritage/Profession/Appearance/Town/Skills-selection/Summary all +render retail-shaped; four residuals visible in the captures: + +- **R4-1: Skills credits value overlaps the caption again** + ("Available Skill0Credits") — the re-test-2 wrap fix removed Batch E's + caption-width confinement without re-solving the overlap (its own + report flagged the risk). Root-fix the caption/value geometry from the + authored data (where does retail's value actually sit relative to the + caption on `0x100003F9`?). +- **R4-2: the scrollbar thumb TILES** — multiple diamond sprites stacked + down the track (Summary's overview bar shows ~9, Skills 2) instead of + ONE thumb at the scroll position. The re-test-2 single-sprite-thumb + fallback draws repeated/tiled sprites. +- **R4-3: the skills info-box formula line clips** at the box's bottom + edge (the four-line composition exceeds the authored interior). +- **R4-4: the Appearance help text starts mid-sentence** ("right arrows + next to the article of clothing…") — the opening paragraph is either + scrolled off (box has no visible scrollbar) or missing from the + composition; check what retail authors/composes for that box. + +**RE-TEST 3 fix batch (2026-08-16, R4-1..R4-4) is CODE-COMPLETE, pending the +user's visual gate.** All four findings root-caused and fixed via decomp + +live-DAT evidence, no invented pixel offsets. App suite live-DAT env +5372/3 → 5379/3 (+7, zero regressions); Runtime 1735/0 unchanged; full +solution 14585 tests / 4 skips / 1 failure (Core.Net +`NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge`, +the documented full-solution-only flake — confirmed passing standalone, +unrelated to this batch's files). No client launches. + +- **R4-1 FIXED — root cause was a MISSING raw-edge reflow, not a wrap/clip + gap.** Live-DAT probe: the Skills credits value child (`0x100002f3`) is + BASE-INHERITED across four sibling buttons of DIFFERING widths — + Health/Stamina/Mana at 150px share the exact same child id/rect (local + X=116) as the wider, 231px Skills credits button — and the child's own + `OriginalParentWidth` (150, baked in wherever it was first resolved, + matching Health's own actual width) diverges from Skills credits' real + 231px parent. `UiButton.ValueBox` was built from the child's RAW + (un-reflowed) rect, never running it through `UiLayoutPolicy` — the SAME + retail raw-edge system (`UIElement::UpdateForParentSizeChange + @0x00462640`) already used for every LIVE mounted `UiElement` via + `UiElement.ApplyAnchor`. The child's own edge modes (Left=2/Right=1, + live-DAT-confirmed "track the far edge as the parent grows") shift the + value box from X=116 to X=197 for Skills credits specifically — landing + immediately after the caption's own measured 193px span (ends ≈x=196) + instead of colliding mid-caption. Separately, `ValueAlign` mapped + `HJustify.Right` (raw dat 3/5, live-DAT-confirmed authored on ALL four + value children) to Center — `UIElement_Text::CalcJustification + @0x00467260`'s own `ecx_5==3||5` branch is a DISTINCT far-edge formula, + not Center's halved offset; added a `LabelAlignment.Right` case. + Health/Stamina/Mana (whose `OriginalParentWidth` already matches their + own actual width) reflow to their byte-identical raw rect — the fix is + additive, not a per-button special case. + Files: `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildButton`'s + value-child block, new `ReflowValueChildRect`), `src/AcDream.App/UI/UiButton.cs` + (`LabelAlignment.Right`, `OnDraw`'s value-draw `vx` switch). Tests: + `DatWidgetFactoryTests.BuildButton_ValueChildBaseInheritedNarrowerParent_ReflowsToWiderButton` + (+ its `..._OriginalParentMatchesActual_RectUnchanged` negative + companion); live-DAT + `CharacterCreationLiveDatTests.SkillsCreditsButton_ValueBoxReflowsPastCaption_HealthValueBoxUnchanged` + (pins the real installed DAT's `197,0,34,28` vs `116,0,34,28`). +- **R4-2 FIXED — the single-sprite-thumb fallback was TILING (UV-repeat) + a small marker graphic instead of drawing it once.** The re-test-2 fix + (R3-4/R3-7) correctly identified the thumb sprite but fed it to + `DrawTiled` (GL_REPEAT UV wrap) — for a small fixed "diamond" marker + drawn into a track-proportional thumb rect far taller than its own + native size (`UIElement_Scrollbar::UpdateLayout @0x4710d0`'s + `max(MinThumb, trackLen*ThumbRatio)` formula, unchanged/still correct + for the rect's SIZE), the texture sampler repeated the marker several + times down the track (~9 on Summary's overview bar, ~2 on Skills, + matching the live capture). New `DrawThumbMarker` draws exactly ONE + instance at the sprite's own native size, centered within the SAME + computed rect — neither tiled (the bug) nor stretched into an elongated + bar (a naive `DrawSprite` fix would have distorted the diamond shape). + The shade slider's own scalar-mode draw path (`DrawVerticalScalar`) was + never touched — it already used the correct native-size `DrawSprite` + pattern this fix now mirrors for model-mode bars. + File: `src/AcDream.App/UI/UiScrollbar.cs` (`DrawVerticalModel`/ + `DrawHorizontalModel`'s fallback branch, new `DrawThumbMarker`). Test: + `UiScrollbarTests.SingleSpriteThumb_DrawsOneUntiledInstance_NotRepeatedDownTrack` + — reads back the actual emitted quad's UV V-coordinate via + `TextRenderer.DebugSpriteSegmentVerts` and asserts it never exceeds 1.0 + (native); confirmed this test FAILS (V=7.875) against the pre-fix + `DrawTiled` call by temporarily reverting and re-running. +- **R4-3 FIXED — the description pane's own authored box is genuinely + taller than the decorative frame that visually contains it.** Live-DAT + geometry walk: the gold frame (`0x100003fa`, the SAME GF-12 corner/edge + sprite family as the Appearance help box) spans Y=430 H=110 (bottom + Y=540), but the description pane (`0x100003fc`) spans Y=460 H=100 + (bottom Y=560) — 20px PAST the frame's own bottom border. Composition- + height simulation against every one of the 38 skills carrying detail + data (real `ChargenTableReader` descriptions + the worst-case + description+bonus+formula line count) confirmed the pane's OWN raw + 100px interior comfortably fits every case (worst: 5 lines / 80px < 90px + interior) — ruling out a wrap-width or line-spacing bug. The real + mismatch is the SIBLING frame's smaller authored bottom, which the pane + was never clamped to, letting a tall composition's last line(s) draw + past the frame's own visible border into blank page space. Retail's + `ShowSkillsText @0x00481250` has no code linking the panes to the frame + (plain `SetText`, no size/clip handoff) — the frame's own authored Y+H + is the only available ground truth, not a decomp-confirmed clip + mechanism, so this is filed as register **AD-105** (a genuine + inference, flagged rather than silently assumed, same shape as R3-3's + own AD-104 scoped correction). `CharacterCreationSkillsPage`'s + constructor now clamps `_infoText.Height` to the frame's bottom edge + whenever it would otherwise be taller (additive; never grows it). + File: `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` + (constructor, new `InfoBoxFrameElementId` clamp block). Tests: fixture + `CharacterCreationUiControllerTests.SkillsPage_InfoBoxDescriptionPane_HeightClampedToFrameBottom` + (the shared `BuildSkillsPage` fixture gained a deliberately-shorter + frame element); live-DAT + `CharacterCreationLiveDatTests.SkillsInfoBoxFrame_ShorterThanDescriptionPane` + (pins the 20px real-DAT mismatch itself). +- **R4-4 FIXED — two stacked gaps, the same "page never touched this + element" shape as prior holdouts.** The help box (`0x100003ab`) is a + purely DAT-authored static paragraph (no `gmCGAppearancePage` runtime + composition function exists for it, unlike Town/Summary's + `SetTownString`/`SetHowToText` — confirmed absent from the named + decomp) that `CharacterCreationAppearancePage` never referenced at all, + so it kept `UiText`'s own chat-style default + (`PreserveEndOnLayout=true`, "keep a view that is already at the end + pinned there"). Its content overflows a 292px-tall frame, and + `UiScrollable.SetExtents`'s own `wasAtEnd` check is vacuously true the + FIRST time a Scroll model transitions from its zero-initialized state + (`ContentHeight=0/ViewHeight=0/ScrollY=0` → `MaxScroll=0` → + `AtEnd=(0>=0)=true`) to real overflowing content — with + `PreserveEndOnLayout` still true, that spuriously pins the very first + render to the BOTTOM, hiding the opening paragraphs exactly as reported + (the visible text is mid-way through the third paragraph). This is a + static instructions box, not a chat transcript — `PreserveEndOnLayout`'s + own doc already carves out exactly this shape ("top-oriented reports + such as Character Information disable it"). Also wired the box's own + NESTED authored scrollbar (property `0x72`, live-DAT-confirmed a direct + Type-11 child of the text box — the SAME nesting shape + `CharacterCreationSummaryPage.HowToScrollRelativeId` already uses) — + never wired by this page before, so a user can reach the rest of the + text even where the box's own height still doesn't fit everything. + File: `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` + (constructor, new `HelpTextId`/`HelpScrollRelativeId` block). Tests: + fixture + `CharacterCreationUiControllerTests.AppearancePage_HelpText_TopOriented_AndOwnScrollbarIsWired` + (the shared `BuildAppearancePage` fixture gained the help box + its + nested scrollbar child, StateMedia-bearing so `UiText`'s own dat- + children carve-out actually builds it). + +## GATE PASSED — 2026-08-16, build `1.0.2-cc.o` + +The user's final pass on `1.0.2-cc.o` (carrying the R4 fixes) returned +**"Ok works. Gate pass!"** — Campaign CC's connected gate is CLOSED +USER-ACCEPTED. Every finding family in this doc (GF-1..16, R2-1..8, +R3-1..9, R4-1..4) is fixed and user-verified, except the two explicitly +dispositioned non-bugs: the `[ Name ]` prefill (three independent +exhaustive negatives — retail's field is empty; closed as a recollection +slip unless retail evidence surfaces) and the items deferred to their own +tracked issues (#408 general authored-Invisible, #409 tooltips, #410 +VJustify default, AP-231's formula connector). diff --git a/docs/research/2026-08-16-campaign-cc-test-script.md b/docs/research/2026-08-16-campaign-cc-test-script.md new file mode 100644 index 00000000..c9572d2c --- /dev/null +++ b/docs/research/2026-08-16-campaign-cc-test-script.md @@ -0,0 +1,395 @@ +# Campaign CC connected-gate test script + +**Status: the campaign is CODE-COMPLETE (CC1-CC7) and this script is its +connected-gate contract.** Every step below is the user's own eyes on the +running client — nothing here was run automatically. **No automated live +character creation has been run against ACE** (see §CC-Not-Automated) — +the first LIVE create is deliberately left to this gate. + +This script covers TWO ways to reach the chargen screen: the real launcher +flow (Campaign LA's product path) and the developer shortcut +(`ACDREAM_RETAIL_UI=1` + `ACDREAM_OPEN_CHARGEN=1`, still available and still +useful for a fast create-only iteration loop). Both land on the exact same +screen and Runtime owner — there is no second code path being tested. + +Local ACE connection details (per `CLAUDE.md`): host `127.0.0.1`, port +`9000`, account `testaccount` / `testpassword`. Use a FRESH character name +per attempt (ACE does not forget names within a session) — `CC--` +is a good scheme, e.g. `CCAB1`, `CCAB2`. + +--- + +## §CC1 — reaching the screen + +### Path A — the launcher (product path) + +1. Launch `AcDream.Launcher` (already installed/updated per Campaign LA's + own gates — this script does not re-run first-run setup or the update + flow; see `docs/research/2026-08-14-campaign-la-test-script.md` if either + is in question). +2. Open **Check for updates**. Confirm it reports the client already + current (no install prompt) — Campaign LA's own gates already proved the + install/update mechanics; this step just confirms nothing is stale + before the character-creation gate. +3. Confirm (or create) a profile pointed at `127.0.0.1:9000`, + `testaccount` / `testpassword`. +4. Click **GUI — character select**. The retail character-management + screen (`gmCharacterManagementUI`) opens — the flat character list, World + name, Enter/Delete/Restore buttons, and the **Create** button. + +### Path B — the developer shortcut + +```powershell +$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" +$env:ACDREAM_RETAIL_UI = "1" +$env:ACDREAM_LIVE = "1" +$env:ACDREAM_TEST_HOST = "127.0.0.1" +$env:ACDREAM_TEST_PORT = "9000" +$env:ACDREAM_TEST_USER = "testaccount" +$env:ACDREAM_TEST_PASS = "testpassword" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release +``` + +Confirm the SAME character-management screen appears. `ACDREAM_OPEN_CHARGEN=1` +(add it to the block above) is the CC4-era interim seam that opens the +chargen screen directly on startup, skipping the Create click — still +useful for a fast create-only loop, but Path A/B above is now the REAL path +and should be exercised at least once per gate. + +### The Create button + +1. With at least one free character slot (roster count below the account's + allowed slot count — most test accounts have several free slots), + confirm **Create** is ENABLED (not greyed out). +2. **Click Create.** The chargen screen (`gmCharGenMainUI`) opens directly + on the **Heritage** page — no confirmation, no loading screen. The + character-management screen you were just on is not closed or hidden; + it simply sits behind the new screen (this matters for the Exit step + below). +3. **If your account's roster is completely full** (rare on a fresh test + account — every slot occupied), confirm Create is instead GREYED OUT + and does nothing when clicked. This is retail's own gate + (`gmCharacterManagementUI::UpdateButtons`) — a full roster ghosts + Create exactly like Enter/Delete grey out for an unselected row. + **A fresh test account is unlikely to be full on its own** (ACE's + default `max_chars_per_account` is 11) — force this state instead of + waiting for it: + 1. From the ACE server console (or a GM-privileged in-game `@` command), + run `@modifylong max_chars_per_account 2` to lower the ceiling below + your current roster count. + 2. Reconnect (a fresh `CharacterList` only arrives on a new connection — + the client does not re-fetch it live) and confirm Create is now + GREYED OUT. + 3. Restore the default afterward: `@modifylong max_chars_per_account 11`, + then reconnect again and confirm Create is enabled once more. + 4. **The count includes pending-delete (greyed) characters** — a + character mid-deletion still occupies a roster slot both in ACE's + `GameMessageCharacterList` and in acdream's own gate + (`RuntimeCharacterSelectionState.BuildButtons`'s + `_entries.Length < _slotCount`, which counts every roster entry + regardless of pending-delete state) — matching retail's own + `RebuildCharacterList`, which walks the same full set. If you have a + pending-delete character sitting around, it still counts toward the + ceiling above. + +### Leaving the screen (Back-at-Heritage and Exit) + +1. On the **Heritage** page (the first page), click **Back**. Confirm a + confirmation dialog appears asking whether to leave character creation + (same text/shape as the Exit button below — Back-at-Heritage and Exit + share retail's one `DoExit` confirmation). +2. Click **Exit** (top of the screen, available on every page). Confirm + the SAME confirmation dialog appears. +3. **Confirm the dialog (accept).** The chargen screen closes. You are back + at the character-management screen you started from — roster, World + name, and any prior selection are exactly as you left them (character + management was never hidden, so there is nothing to "restore"). +4. Click **Cancel** on a repeat Exit attempt instead — confirm the dialog + closes and chargen stays open, untouched. + +### What to report for §CC1 + +- Create's enabled/greyed state matching the free-slot count you actually + have. +- Whether clicking Create opens chargen with no lag/flash/black frame. +- Whether returning from Exit shows the character list exactly as it was + (no re-flicker, no lost highlight, no stale World name). + +--- + +## §CC2 — the six-page create flow + +**The screen does NOT open blank.** `gmCharGenMainUI`'s own constructor +rolls a full random character (heritage, gender, appearance, clothing, +template, start area) before the Heritage page ever draws — acdream ports +this faithfully (AP-214, retired). **Expected retail quirk: the gender +shown on the Appearance page is the FLIP of the roll** — the Appearance +page's own init code reads the just-rolled gender and immediately swaps it +to the opposite one. If you open chargen and see (say) a female Aluvian +with the Appearance page showing "Male" selected, that is CORRECT, not a +bug — do not report it. + +### Heritage page + +1. Confirm one of the 13 heritage buttons is already highlighted (the + opening roll) — Human heritages (Aluvian/Gharu'ndim/Sho/Viamontian), + Tumerok, Gearknight, Lugian, Empyrean, Penumbraen, Shadowbound, Undead, + Olthoi, and OlthoiAcid should all be selectable and each show its own + description text (starting skills, bonus-skills paragraph where retail + has one — Lugian/Olthoi/OlthoiAcid have none, that's retail-correct). +2. Click **Random**. Confirm the highlighted heritage changes to a + uniformly-picked one of the 13 — this is AP-212's documented + approximation (retail's own Heritage-page Random rolls with retail's own + distribution; acdream picks uniformly over every installed heritage). + A uniform pick over 13 can land back on the heritage you already have — + click a few times if the first click looks like a no-op; occasional + repeats are expected, not a bug. Not a bug to report unless the button + does nothing or crashes across several clicks. +3. Select **Olthoi** or **OlthoiAcid**. Confirm the Profession, Skills, and + Town tabs are hidden (Olthoi variants skip straight to a fixed Custom + template with no attribute/skill/town choices) and the screen + auto-advances past them. + +### Profession page + +1. Select a NON-Olthoi heritage. Confirm 7 template buttons (Custom + 6 + presets) and 6 attribute sliders (Strength/Endurance/Coordination/ + Quickness/Focus/Self). +2. Drag a slider. Confirm the numeric readout updates live and the + Available-credits counter decreases/increases correspondingly. +3. Click a preset template (not Custom). Confirm all 6 sliders jump to + that preset's values and Available credits updates to match. +4. Click **Random**. Confirm heritage/template selection changes (AP-212 — + uniform pick, not retail's own weighted roll). + +### Skills page + +1. Confirm ONE flat listbox of skills, each row showing name, current + level, and train/specialize costs (AP-213 — retail groups these into + four sorted buckets; acdream's flat list is a presentation + simplification, not a rules difference — do not report the flat + ordering as a bug). +2. Click a trainable row. Confirm it advances (Untrained -> Trained -> + Specialized) and the skill-credits meter decreases; a second click on an + already-Specialized row does nothing further (no fourth state). +3. Confirm **Random** is disabled/greyed on this page (retail's + `RandomizeSkills` primitive is unported — AP-212's own documented gap). + +### Appearance page + +1. Confirm **Face** and **Clothes** sub-tabs, nine spin controls (hair, + eyes, nose, mouth, skin under Face; headgear, shirt, trousers, footwear + under Clothes), nine color swatches, a shade scrollbar, zoom/rotate + buttons, and a live 3D preview playing an idle animation loop. +2. Click a spin's left/right arrow zones. Confirm the selected style index + advances/retreats and the preview model updates. +3. Click a color swatch. Confirm the swatch shows a highlighted "selected" + ring/border (AP-215 — acdream's own selection indicator, not retail's + overlay mechanism; functionally equivalent). +4. Click **Zoom In**. Confirm the preview freezes its pose (idle animation + stops) and the camera tweens closer over about half a second. Click + **Zoom Out** — animation resumes, camera tweens back out. +5. Click **Rotate Clockwise**/**Counter-Clockwise**. Confirm the model + spins continuously at a steady rate (about 3 seconds per full turn); + clicking the SAME direction again stops it, clicking the OPPOSITE + direction reverses it. +6. Click **Random** (on either sub-tab). Confirm hair/eyes/nose/mouth/skin + (Face) or headgear/shirt/trousers/footwear (Clothes) all re-roll + together — this IS retail's real `RandomizeAppearance`/ + `RandomizeClothing` primitive (CC5 ported it verbatim, not approximated). +7. Select **Gearknight**, **Olthoi**, or **OlthoiAcid** on the Heritage + page, then return to Appearance. Confirm the Clothes sub-tab and its + four spins are unreachable, Nose/Mouth spins are hidden, and Eyes' + arrows are disabled (fixed eyes for these forms). + +Known cosmetic gaps on this page — expected, do not report as bugs unless +noticeably worse than described: swatches show static art rather than the +actual color they represent (AP-216); the gradient circle art next to the +swatches never repaints to reflect the current color (AP-217); the four +icon-only spins (hair/eyes/nose/mouth) show a plain number instead of an +icon thumbnail, while the four clothing spins show real names (AP-215/ +AP-218); on Olthoi/OlthoiAcid/Gearknight the Skin spin does not slide up to +close the gap left by the hidden Nose/Mouth spins (AP-219); switching +heritage INTO or OUT OF Gearknight does not automatically re-roll +appearance/clothing the way retail does on that exact transition (AP-220); +the currently-selected spin shows no distinct highlighted state versus the +other eight (AP-222 — this is a MEASURED gap in acdream's own art, not yet +attributed to a specific missing asset; report clearly if you can visually +compare with retail here). + +**Known session-permanent gap (AP-221) — check the console before +reporting a dead preview.** On an unlucky frame where the DAT/GPU resource +read backing the 3D preview isn't ready at the client's single composition +pass, the Appearance page's zoom/rotate controls can go dead for the rest +of the session (or the Summary page's preview can simply never render), +with no on-screen error — the only evidence is a console line: +`[UI] chargen preview viewport unavailable at composition time...` (or the +Summary-page sibling, `[UI] summary preview viewport unavailable at +composition time...`). If zoom/rotate stop responding or a preview stays +blank, check the console for one of these lines FIRST. If it's there, +restart the client and retry before reporting a bug — this is a known, +already-registered gap, not a new one. + +### Town page + +1. Confirm four town buttons: Holtburg, Shoushi, Yaraq, Sanamar (not id + order — that's retail's own literal ordering, ported faithfully), each + with descriptive text. +2. Click a town. Confirm it highlights and the description text updates. + +### Summary page + +1. Confirm a listbox showing Profession, Gender, Heritage, and Starting + Town lines, an "Attributes" header, then Strength/Endurance/ + Coordination/Quickness/Focus/Self/Health/Stamina/Mana/Skill Credits as + paired rows, then Specialized and Trained skill name lists (retail also + lists the two Untrained buckets; acdream's Summary omits them — + AP-224, same class of cut as the Skills page's own AP-213). +2. Confirm a static 3D preview of the character (no zoom/rotate controls + on this page — retail has none here either). +3. Click the **name field** and type a name. Confirm only letters, spaces, + apostrophes, and hyphens are accepted (other characters are silently + rejected keystroke-by-keystroke). +4. Click **Random** on this page. Confirm a confirmation dialog appears + first ("are you sure you want to randomize?"); confirming it re-rolls + the ENTIRE character (heritage through name) using retail's real + `RandomizeCharacter` primitive — the same one the screen-open roll uses. + +### What to report for §CC2 + +- Any page that fails to render a control listed above, or where a control + visibly does nothing when clicked. +- Anything from the "known cosmetic gaps" list that looks MORE broken than + described (e.g. a spin that doesn't advance at all, not just a missing + highlight). +- Any crash, freeze, or console error while navigating pages or the tabs. + +--- + +## §CC3 — Finish and its dialogs + +Use a fresh, never-before-used character name for the happy path. For every +scenario below, watch the console/log for `[UI]`/`[CC]`-prefixed lines — +they help distinguish "nothing happened because the click didn't register" +from "the request went out and ACE is thinking about it." + +### Happy path + +1. Complete a legal character (heritage, gender, template with credits + fully spent, at least a default set of skills, a town, a fresh name). +2. Click **Finish** on the Summary page. Confirm the screen closes almost + immediately (no visible "please wait" dialog for a normal accept — ACE's + Ok reply is fast) and you land DIRECTLY in the world as the new + character — no return to character management, no fresh character list, + matching retail's own "log straight in" behavior. +3. If you back out to character management instead (e.g. via a later + logout), confirm the new character now appears in the roster alongside + any pre-existing ones, in the correct slot. + +### NameInUse (duplicate name) + +1. Create a SECOND character using the EXACT name you just used above. +2. Click Finish. Confirm the `ID_Character_Err_NameReserved` dialog appears + ("that name is in use" / similar text) and you stay on the chargen + screen — Finish is clickable again afterward. +3. **Expected log noise (register AD-100):** ACE sends the NameInUse + rejection TWICE for the same request (a real ACE double-send bug, not + an acdream defect). The FIRST reply drives the dialog above; the SECOND + logs something like `unexpected CharacterGenerationVerificationResponse` + in the console. That log line is EXPECTED here — do not report it as an + error. + +### The credit-warning confirm flow + +1. Select the **Custom** template on the Profession page (leaves several + attribute credits unspent) and complete the rest of the character. +2. Click Finish. Confirm a warning dialog appears about unspent attribute + credits, and Finish does NOT send anything yet. +3. Confirm the dialog. Confirm the request now sends anyway, with the + unspent credits — this is retail-correct (`DoFinish`'s own confirm-arm + skips the credit check entirely; ACE accepts an under-spent build). + +### The randomize warning flow + +Already covered in §CC2's Summary-page step 4 above — confirm the warning +appears BEFORE any randomization happens, and Cancel leaves the character +completely untouched. + +### The exit warning flow + +Already covered in §CC1's "Leaving the screen" section above. + +### NameTooLong + +1. On the Summary page, type or paste a name longer than 32 characters and + commit it (press Enter, or click elsewhere to move focus away from the + field). +2. Confirm the `ID_CharGen_NameTooLong` dialog appears and the field + reverts to its previous (shorter) value — the field itself does not cap + your typing at 32 characters as you go; the rejection only fires on + commit. That is retail-correct, not a bug. + +### Empty name (AP-227, an acdream/retail divergence — expected) + +1. On the Summary page, select all the text in the name field and delete + it entirely, then blur the field (click elsewhere) without typing a + replacement. +2. Click **Finish**. Confirm the `NoName`/`ID_CharGen_NoNameWarning` dialog + appears — acdream clears its internal name state the instant the field + is emptied, so Finish sees an empty name and refuses. **This is NOT what + retail does**: retail's own commit handler only acts when the field's + length is greater than 1 (NUL-inclusive, so an empty field's length is + exactly 1) — an emptied-then-blurred field is a silent no-op in retail, + and the character's internal name stays whatever it was BEFORE you + cleared the field, even though the field visually shows empty. A real + retail client would create the character under that old, uncleared name + here instead of showing a dialog. Expect acdream's dialog, not retail's + silent keep-old-name behavior — register AP-227. + +### What to report for §CC3 + +- Whether the happy path truly lands you in-world with no intermediate + screen. +- The exact dialog text shown for each rejection (useful for a later + string-table audit even if it looks right). +- Any case where Finish appears to do nothing at all (no dialog, no + console line, no world entry) — that would be a real regression, not one + of the documented cosmetic gaps above. + +--- + +## §CC4 — ACE-side landmines (not acdream defects) + +- **Heritage-priced skill over-deduction — LATENT, will not fire with the + installed EoR DAT.** ACE's `PlayerFactory.CreatePlayer` has a real + overcharge bug when specializing a skill priced by the active heritage's + own skill list (versus the global skill table). It was measured against + the installed EoR data and found unreachable — every heritage's one + priced skill (Arcane Lore) has a heritage NormalCost of 0, which makes + ACE's overcharge exactly zero. You should NOT be able to trigger a + `FailedToSpecializeSkill` rejection from a retail-legal build during this + gate. If you somehow do, that is worth flagging immediately — it would + mean the installed DAT's costs differ from what was measured. +- **Disabled-Olthoi create -> Pending -> NameDBDown dialog is + retail-correct.** If your local ACE has Olthoi character creation + disabled (a server config option), attempting to create an Olthoi/ + OlthoiAcid character will surface the `ID_Character_Err_NameDBDown` + dialog via a `Pending` response code. This is retail's OWN behavior + (ACE's `olthoi_play_disabled` branch sends `Pending`, and retail's + dispatch has no silent branch for it) — not a "the client swallowed my + request" bug. + +--- + +## §CC-Not-Automated + +No automated test in this repository has created a character against a +LIVE ACE server. Every field-shape and response-code assertion in Campaign +CC's test suite runs against a real `WorldSession` and hand-built response +packets (see `tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs`) +— the wire bytes and the state machine are proven byte-for-byte, but the +FIRST character ever created against a real, running ACE process is +whatever you create during this gate. Server state (what names exist, +what heritages are enabled, what the account's slot count is) is entirely +yours to observe; this script deliberately does not assume any of it in +advance. diff --git a/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md new file mode 100644 index 00000000..0224a7f7 --- /dev/null +++ b/docs/superpowers/specs/2026-08-14-launcher-campaign-design.md @@ -0,0 +1,389 @@ +# Campaign LA — Launcher / Installer / Updater + character-select screen (design spec) + +**Date:** 2026-08-14 +**Status:** APPROVED design, pre-plan +**Plan doc (next step):** `docs/plans/2026-08-14-launcher-campaign.md` (to be written) +**Prior decisions consumed:** `claude-memory/project_launcher_direction.md` (2026-08-09) + +Campaign LA is distinct from Modern Runtime **Slice L** (Linux graphical, +parked at L1) — "LA" is a campaign identifier in the N/V/P/A/CH/OP/FA +series, not a slice. + +## 1. Goal + +One external product — the **acdream launcher** — that is simultaneously +the installer, the updater, and the multi-server / multi-account / +multi-character session launcher (ThwargLauncher UX model), on Windows and +Linux. Plus the one client-side feature the launcher flow exposes as +missing: the **retail character-selection screen**. + +Distribution model: alpha users receive ONLY the launcher (per-RID +self-contained single-file publish). The launcher fetches the client from +the release feed, locates retail DATs, runs the pak bake, and launches +sessions. + +## 2. Decisions log + +Pinned 2026-08-09 (NOT re-litigated here): + +| Decision | Value | +|---|---| +| UI stack | Avalonia (Windows + Linux day one) | +| Project split | `AcDream.Launcher` (thin Avalonia) + `AcDream.Launcher.Core` (BCL-only) | +| Credentials | **Plaintext file, user-decided.** 0600 on Linux; never in logs/crash bundles | +| UX reference | ThwargLauncher (servers × accounts × character pre-select) | +| Launch contract | Reuse Slice K1's strict portable config shape | +| Paths | Consume Slice L0's `ApplicationPathSet` XDG/Windows contract | + +Decided this session (2026-08-14): + +| Question | Decision | +|---|---| +| Headless launch purpose | **Run plugins** (VirindiTank-style) + login commands; same on GUI. Launcher selects which plugins per character | +| Character-select screen | **Retail screen, no Create.** CORRECTED by 2026-08-14 recon: retail's `gmCharacterManagementUI` is a flat listbox + Enter Game / Delete / Restore buttons + dialogs — **no 3D preview exists on retail's select screen** (that machinery, `gmCG3DView`, is character-CREATION-only). We port what retail actually had; Create deferred to its own campaign | +| Campaign scope | **Everything now** — launch flows + first-run install + update pipeline in one campaign | +| Launcher ↔ client coupling | **Approach A: file-contract orchestrator** (config in, status events out; no game-protocol code in the launcher) | +| Update feed | **GitHub Releases** (manifest.json + per-RID zips as release assets) | +| Profile editing | **Full CRUD in the launcher UI** — add/edit/remove servers, accounts, passwords, per-character settings. The JSON file is storage (hand-editable as a bonus), never the required interface | +| Linux scope | **Full launcher stack Linux-tested in LA** (launcher UI, install/update, headless + plugins + probe); GUI launches stay Windows-only until Slice L resumes later (user 2026-08-14). Launcher disables GUI modes on Linux with an explicit note | +| Character enumeration | **On-demand probe**: launcher spawns the headless host in a probe mode (connect → `CharacterList` → status event → graceful disconnect BEFORE entering world → exit) and folds the roster into the profile store | + +Rejected: the launcher itself embedding Runtime/Core.Net to speak the +game protocol — the character probe runs in the headless host via the +normal launch contract, so the launcher stays protocol-free. The probe +never enters the world; a graceful account-level disconnect at the +character-list stage is the same dance every normal login performs, so +the ACE stale-session landmine (hard-killed in-world sessions poisoning +the account ~3 min) does not apply on the happy path. Deferred: live IPC +fleet dashboard (the status-file format is its forward seam), Create +Character, community server-list import. + +## 3. Architecture — file-contract orchestrator + +The launcher never speaks the game protocol and references nothing from +the game solution except a new tiny platform assembly. Its contracts with +the client are exactly three: + +1. **Config in** — a per-launch session config file (K1 shape, extended). +2. **Credential in** — password piped to child stdin (K1 `StandardInput` + provider). +3. **Status out** — a per-session JSON-lines event file the launcher tails. + +Character enumeration has two feeds, both flowing through the same +status-stream vocabulary: + +1. **Cache-from-observation** — hosts report the account's + `CharacterList` in the status stream on every login; the launcher + folds it into its profile store. +2. **On-demand probe** — a "refresh characters" action per account spawns + the headless host with a probe-mode session config: connect, receive + `CharacterList`, emit the status event, gracefully disconnect + **without entering the world**, exit. The launcher folds the roster in + exactly as in (1). The launcher refuses to probe an account it is + itself currently running a session for; an externally-active session + makes the probe fail gracefully (reported on the status stream, never + an exception in the launcher). + +A never-seen account can therefore be enumerated before its first real +launch, or simply launched in `guiSelect` mode and picked in-client. + +## 4. Components + +- **`AcDream.Launcher.Core`** (new, BCL-only): profile store + (load/save/validate/merge-charlist), session-config composition, process + spawn + supervision + stdin credential feed, status-event reader, + install engine (DAT locate/validate, bake-tool invocation, SHA verify), + update engine (manifest client, download, SHA verify, versioned install, + pointer swap), self-update stager. Fully unit-testable. +- **`AcDream.Launcher`** (new, Avalonia): MVVM shell over Launcher.Core. + Server list → accounts → characters tree with **full CRUD in the UI**: + add/edit/remove servers (name/host/port), add/edit/remove accounts + (account name + password entry), per-character settings editor (launch + mode / plugins / login commands), a per-account "refresh characters" + probe action, session status column, first-run install wizard, update + prompts. Hand-editing the JSON is never required for any flow. +- **`AcDream.Platform`** (new, tiny, BCL-only): `ApplicationPathSet` + + `IApplicationPathEnvironment` move here from + `src/AcDream.Runtime/Platform/ApplicationPathSet.cs`. Runtime, App, + Headless, Launcher.Core reference it. Dependency guards (K0 family) + amended deliberately in the same commit. +- **`AcDream.App`**: gains `--session-config ` CLI ingestion into + `RuntimeOptions` (env-var dev workflow untouched), the `StandardInput` + credential resolver, the status-event writer, the launcher-selected + plugin set, login-command execution, and the character-select screen. +- **`AcDream.Headless`**: gains the two config fields (`Plugins`, + `LoginCommands`), an `idle` consumer policy (enter world, run + plugins/commands, stay until stopped), the probe mode (§3/§6), plugin + hosting, and the same status-event writer. + +## 5. Profile & credential store + +One JSON file, created and maintained entirely by the launcher UI (the +CRUD flows in §4): `ConfigDirectory/launcher-profiles.json` +(`%APPDATA%\acdream\` / `~/.config/acdream/`), permissions 0600 on Linux. +Hand-editability is a property of the format, not a required workflow. + +```json +{ + "version": 1, + "servers": [ + { + "name": "Local ACE", + "host": "127.0.0.1", + "port": 9000, + "accounts": [ + { + "account": "testaccount", + "password": "testpassword", + "characters": [ + { + "name": "+Acdream", + "id": "0x5000000A", + "launchMode": "gui", + "plugins": ["ExamplePlugin"], + "loginCommands": ["/tell someone, hi", "/vt start"] + } + ] + } + ] + } + ] +} +``` + +- `characters[]` = launcher-maintained cache (name/id, fed by status + events) + user settings (`launchMode`, `plugins`, `loginCommands`). +- `launchMode`: `gui` (straight to world), `guiSelect` (GUI, stop at + character-select screen; default when no character chosen), `headless`. +- Passwords live in this file and NOWHERE else: never in process + arguments, never in session configs, never in logs (K1 redaction + discipline extends to the launcher). +- Server entries are manual-add (name/host/port). No published-list + import this campaign. + +## 6. Launch contract + +**Session config** (written to +`CacheDirectory/launcher/sessions//session.json`): the K1 +`HeadlessConfiguration` shape extended with: + +- `Plugins: string[]` — plugin names to load from the standard + `PluginsDirectory`; absent/null loads all discovered plugins, while an + explicit empty array loads none. Launcher-composed normal-empty and probe + sessions emit the empty array. +- `LoginCommands: string[]` — ordered chat-typed strings. +- Graphical host: `Character` selector may be ABSENT → character-select + screen instead of auto-enter. +- `Content` descriptor (existing K1 field): `DatDirectory` + + `PreparedAssetPath`, filled from the launcher's install records. + +**Spawn:** + +- Headless: `AcDream.Headless --config ` (existing CLI). +- GUI: `AcDream.App --session-config ` (new; parsed once in + `Program.cs` into `RuntimeOptions` per code-structure rule 4). +- Probe: `AcDream.Headless --config ` with a probe-mode session + (connect → `characterList` status event → graceful disconnect before + `EnterWorld` → exit). Today's config loader requires a character + selector and a policy per session (`JsonRequired`); probe mode relaxes + that for the probe shape only. +- Credential: K1 `StandardInput` provider; launcher writes the password + to child stdin then closes it. Headless supports this today; App gains + the resolver. + +**Status stream** +(`CacheDirectory/launcher/sessions//status.jsonl`), appended by both +hosts, one JSON object per line: + +`started`, `connected`, `characterList` (names + ids + slots), +`enteredWorld` (id + name), `pluginLoaded` / `pluginFailed` (name + +error), `loginCommandFailed` (zero-based command index + exact configured +line + isolated error), `disconnected`, `exited` (code + reason). + +The launcher tails this for live per-session UI state and folds +`characterList` into the profile store. This exact event vocabulary is +the seam a future IPC channel (fleet dashboard) replaces — same events, +different transport — so event names/payloads are versioned from day one +(`"v": 1` per line). + +## 7. Character-select screen (client-side, retail) + +A new pre-world session state between `CharacterList` receipt and +`EnterWorld`. Today `LiveSessionController` (`TrySelectCharacter`, +`src/AcDream.Runtime/Session/LiveSessionController.cs`) auto-selects and +enters immediately. New behavior: **no character selector in options → +stop at the retail character-select screen.** Selection there feeds the +same `EnterWorld` path. + +**CORRECTED 2026-08-14 (named-retail recon):** retail's screen is +`gmCharacterManagementUI` (`acclient.h:56545`) — a `UIElement_ListBox` +character list plus Create / Enter Game / Delete / Restore buttons and +dialog contexts (delete-confirm, please-wait, entering-world, error). +**It has NO 3D preview** — the rotating-model viewport (`gmCG3DView` / +`UIElement_Viewport` / `CreatureMode`) exists only on character +CREATION's appearance/heritage/profession pages. The earlier +"3D preview on a pedestal" belief traced to one uncited line in +`docs/research/retail-ui/05-panels.md` §13. We port the real screen; a +preview would be a deliberate divergence we are NOT taking. + +- **Ownership:** J-owner pattern. A Runtime-owned selection state (roster + incl. greyed/pending-delete seconds, highlighted entry, pending-delete + confirmation) with typed commands (highlight / enter / delete-request / + delete-confirm / restore); App projects the authored screen. Headless + never uses it (config always carries a selector; the loader already + requires one). +- **UI:** imported retail screen. The root layout id is resolved + indirectly — retail calls + `UIMainFramework::CreateAndAddRootElement(0x10000005, 0x1000039a)` and + resolves the concrete DataID via `DBObj::GetDIDByEnum(..., 5)` (the same + GetDIDByEnum machinery OP8 already ported for key names, category 4). + Child widget ids from the decomp: listbox `0x1000039d`, create + `0x100003a0` (hidden/no-op this campaign), enter `0x100003a2`, delete + `0x1000039f`, restore `0x1000039e`. Behavior oracles: + `RebuildCharacterList@0x004ec3a0`, `SelectCharacter@0x004ec160`, + `UpdateButtons@0x004ec240` (Delete↔Restore visibility swap on greyed + state), `EnterGame@0x004ed440`, + `MakeDeleteCharacterConfirmationDialog@0x004ecca0`. +- **Wire (new messages):** `CharacterDelete` 0xF655 (outbound: account + String16L + **slot index**, per `Proto_UI::SendDeleteCharacter@0x00546b30` + — NOT the guid; inbound: opcode-only ack, then a fresh CharacterList), + `CharacterRestore` 0xF7D9 (guid) with response 0xF643, and a + `CharacterError` 0xF659 parser (currently absent — acdream cannot + surface any character-stage server error today). Enter-world's + two-phase handshake (0xF7C8 → 0xF7DF → 0xF657) is already implemented. +- **Open items** carried to the plan: the concrete layout DataID (dump + enum-table 5 from installed DATs), and whether retail rendered any + render-loop-level background scene behind the UI (the pseudo-C only + proves the UI class owns no viewport) — both resolved in the screen + slice before the user visual gate. +- **Non-goals:** Create Character (own future campaign; the Create button + exists on the authored screen but is disabled); a login screen ("back" + exits the client — credentials always arrive via config/env). +- Retail-workflow rules apply: any behavioral deviation ships with its + divergence-register row in the same commit. + +## 8. Plugins & login commands on both hosts + +- **Plugin loading** mechanics already live in Core + (`src/AcDream.Core/Plugins/PluginLoader.cs`, collectible ALC, + `IPluginHost` from `AcDream.Plugin.Abstractions`). This campaign makes + the loaded SET session-config-driven on both hosts. +- **Headless plugin host:** an `IPluginHost` implementation over Runtime + state. UI-only surfaces (`IUiRegistry.AddMarkupPanel`, etc.) become + explicit no-ops behind a capability flag so plugins can detect headless. + Contract documented in `Plugin.Abstractions`. +- **Login commands** run *as if typed into chat*: sequentially, with a + default 500 ms inter-command delay (config-overridable per session), + starting at entered-world. Failures are logged to the status stream and + do not abort the session. +- **The parser seam:** chat-string parsing/dispatch (`ChatInputParser`, + `ChatCommandRouter`, 152-verb `RetailClientCommandCatalog`) lives in + `AcDream.UI.Abstractions`, unreachable from Headless under the K0 + dependency guard. The campaign extracts the parsing/dispatch CORE to a + location both hosts reach — Runtime vs a small shared assembly is + decided in the plan after reading the CH seams. The K0 guard amendment + is a deliberate, documented change in the same slice. GUI chat behavior + must be bit-identical before/after the extraction (CH campaign is + closed and user-accepted; this must not reopen it). + +## 9. Installer / updater + +- **First-run wizard:** auto-detect DAT directories + (`%USERPROFILE%\Documents\Asheron's Call`, `C:\Turbine\Asheron's Call`) + + manual picker; validate the four DAT files; run bake tool 4 with a + real progress UI (~30 GB read); SHA-verify the pak; record + `DatDirectory` + `PreparedAssetPath` for session configs. +- **Client install/update:** poll the GitHub Releases feed's + `manifest.json` (version, per-RID zip URL, SHA-256); download; verify; + install to `DataDirectory/app//`; atomic pointer swap + (`current.json`); never while any session is running; keep the previous + version for one-step rollback. +- **Launcher self-update:** same feed; staged download; target-local atomic + replacement on next start after the running process exits. +- **Feed hosting:** GitHub Releases (user-confirmed). Manifest and zips + are release assets; the launcher pins the repo/owner in its config. + +The exact v1 manifest, extracted-version record, `current.json` activation +pointer and launcher ownership record, shared-session/exclusive-update OS +lease, and durable self-update plan schema 3 are pinned in +`docs/plans/2026-08-14-launcher-campaign.md` under **Pinned updater +contracts (v1, BINDING)**. That section is normative: implementations reject +unknown/duplicate fields and unsupported versions, use strict SemVer 2.0 +precedence, verify bounded streamed downloads before safe ZIP extraction, and +use per-hop redirect validation plus same-filesystem atomic replacement. The +LA9 DAT/pak install record remains the sole content descriptor fed to session +configs; LA10 changes only which verified `app/current.json` client binaries +the process supervisor executes. + +## 10. Testing + +- **Launcher.Core unit tests** (new test project, registered in + `AcDream.slnx`): profile round-trip + merge, full CRUD operations + (add/edit/remove servers/accounts/characters surviving save/load), + session-config composition (including the probe shape), manifest/SHA + against a local HTTP fixture, process supervision + stdin feed against + a fake child, self-update staging. +- **Launch contract:** App/Headless suite round-trips — `--session-config` + → `RuntimeOptions`, stdin credential resolution, status-event writer + output shape, plugin-set narrowing, login-command execution order. +- **Character-select:** Runtime selection-state tests; authored screen + exercised by focused App tests (UI Studio no longer exists — deleted at + Campaign V); visuals settle at the user gate. +- **Headless plugin host:** fixture plugin in the Headless suite + (load, capability flag, teardown). +- **Connected gates (user-driven):** execute the exact serial matrix in + `docs/research/2026-08-14-campaign-la-test-script.md` only after its + connection-free automated preflight passes. The launcher uses one immutable + process-local config/data/cache path set for the whole matrix; the local feed + URI reaches only updater composition and is never persisted. Cover every + launch mode against local ACE + (gui / guiSelect / headless), the character probe (fresh account → + refresh → roster appears, and repeated probes leaving no stale ACE + session), clean-profile first-run wizard end-to-end, staged-manifest + update swap, character-select visual matrix, delete flow, + login-commands + plugin observable behavior on both hosts, and + add-server/add-account flows done purely through the UI. + +## 11. Risks / open items for the plan phase + +1. Parser-extraction landing spot (Runtime vs shared assembly) — decide + after reading CH seams; do not regress CH-accepted chat behavior. +2. Current App plugin-loading behavior (what loads today, when) — read + before wiring the session-driven set. +3. RESOLVED by recon except the concrete root-layout DataID: widget tree + + behavior symbols are in §7; the DataID hides behind `GetDIDByEnum` + enum-table 5 (enum `0x10000005`) — dump the table from installed DATs + in the screen slice. +4. RESOLVED by recon, CORRECTED by the LA7a Opus review (2026-08-14): + delete = 0xF655 account+slot (ack opcode-only, then fresh + CharacterList); restore = 0xF7D9 → 0xF643 response; ACE's + `secondsSincePendingDelete` computes to a constant 1 during the grace + window (ACE quirk — treat any non-zero as "pending delete", don't + display it as a countdown). The restore "two extra strings" question + resolved AGAINST the earlier reading: the PDB-paired binary shows two + REAL constant-string arguments (the decompiler mangled their + rendering, not their existence), so retail's request is ≥16 bytes and + our guid-only 8-byte form is an ADAPTATION — register row AD-97, + filed with the LA7a fix round. ACE reads only the guid; holtburger + ships guid-only successfully. +5. Whether retail rendered a render-loop-level background scene behind the + character-management UI — pseudo-C only proves the UI class owns no + viewport. Resolve in the screen slice (user memory of retail + the + visual gate settle it). +6. Bake tool 4 invocation surface from Launcher.Core (in-process reference + vs child process) — child process preferred to keep Launcher.Core free + of game-solution references; confirm the tool's CLI is sufficient. +7. `AcDream.Platform` extraction touches K0-family dependency guards — + amend the guard assertions in the same commit, never loosen silently. +8. Windows profile-file permissions: 0600 is Linux hygiene; Windows keeps + default user-profile ACLs (no extra hardening — accepted plaintext + posture). +9. Probe semantics: verify against ACE source (and one live check) that a + graceful disconnect at the character-list stage leaves no lingering + account session — the design assumes the landmine is exclusive to + hard-killed in-world sessions. Also verify ACE's behavior when a probe + hits an account with an externally-active session (reject vs boot), + and make the probe's failure path graceful either way. +10. Probe config shape: the headless loader's `JsonRequired` character + selector + policy need a deliberate relaxation for probe sessions + only — normal sessions keep strict validation. diff --git a/memory/project_linux_graphical.md b/memory/project_linux_graphical.md index a104e378..df7114de 100644 --- a/memory/project_linux_graphical.md +++ b/memory/project_linux_graphical.md @@ -12,9 +12,10 @@ Linux gameplay or renderer fork. Canonical seams: -- `AcDream.Runtime.Platform.ApplicationPathSet` owns XDG/Windows config, data, - cache, plugin, screenshot, and diagnostic paths for graphical and headless - hosts. +- `AcDream.Platform.ApplicationPathSet` (moved out of Runtime by Campaign LA + LA0, 2026-08-14) owns XDG/Windows config, data, cache, plugin, screenshot, + and diagnostic paths for graphical and headless hosts — and now for the + external launcher, which references only `AcDream.Platform`. - `AcDream.App.Platform.GraphicalHostPlatformServices` owns one startup OS, architecture, RID, native-dependency manifest, path set, and pacing factory. - `PlatformFramePacingWaiterFactory` selects the existing Windows diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj index c79ba665..80489ef1 100644 --- a/src/AcDream.App/AcDream.App.csproj +++ b/src/AcDream.App/AcDream.App.csproj @@ -58,6 +58,11 @@ + + diff --git a/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs b/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs index c643ed07..5b6e72a3 100644 --- a/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs +++ b/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs @@ -12,9 +12,11 @@ using AcDream.Core.Physics; using AcDream.Core.Rendering; using AcDream.Core.Spells; using AcDream.Core.Vfx; +using AcDream.Core.CharGen; using AcDream.Runtime; using AcDream.Runtime.Gameplay; using AcDream.Runtime.Physics; +using AcDream.Runtime.Session; using DatReaderWriter; using Silk.NET.Input; @@ -63,6 +65,14 @@ internal sealed record ContentEffectsAudioDependencies( Action Error) { public RuntimeCharacterState Character => Runtime.CharacterOwner; + + /// Campaign CC slice CC4: the character-creation options + /// install target — see ContentEffectsAudioCompositionPhase.Compose's + /// ChargenOptionsInstalled step and + /// 's own doc + /// for why this is safe at composition time (strictly before any + /// session's Begin). + public LiveSessionController Session => Runtime.Session; } internal interface IGameWindowContentEffectsAudioPublication @@ -96,6 +106,13 @@ internal interface IContentEffectsAudioCompositionFactory RuntimeCharacterState character, MagicCatalog catalog); int GetSpellCount(MagicCatalog catalog); + /// Campaign CC slice CC4: mirrors the + /// / + /// pair's "load off dats, install once onto the owning Runtime state" + /// shape for the chargen options + /// (AcDream.Content.CharGen.ChargenTableReader.Load). + ChargenOptions LoadChargenOptions(IDatReaderWriter dats); + void InstallChargenOptions(LiveSessionController session, ChargenOptions options); IAnimationLoader CreateAnimationLoader( IDatReaderWriter dats, long maximumEstimatedBytes, @@ -166,6 +183,12 @@ internal sealed class RetailContentEffectsAudioCompositionFactory public int GetSpellCount(MagicCatalog catalog) => catalog.SpellTable.Count; + public ChargenOptions LoadChargenOptions(IDatReaderWriter dats) => + AcDream.Content.CharGen.ChargenTableReader.Load(dats); + + public void InstallChargenOptions(LiveSessionController session, ChargenOptions options) => + session.CharacterCreationState.InstallOptions(options); + public IAnimationLoader CreateAnimationLoader( IDatReaderWriter dats, long maximumEstimatedBytes, @@ -270,6 +293,7 @@ internal enum ContentEffectsAudioCompositionPoint PreparedAssetSourcePublished, MagicCatalogPublished, SpellMetadataInstalled, + ChargenOptionsInstalled, AnimationLoaderPublished, CollisionBuilderPublished, EmitterRegistryPublished, @@ -362,6 +386,12 @@ internal sealed class ContentEffectsAudioCompositionPhase : $"spells: loaded {_factory.GetSpellCount(magic)} entries from portal.dat"); Fault(ContentEffectsAudioCompositionPoint.SpellMetadataInstalled); + ChargenOptions chargen = _factory.LoadChargenOptions(dats); + _factory.InstallChargenOptions(_dependencies.Session, chargen); + _dependencies.Log( + $"chargen: loaded {chargen.HeritagesById.Count} heritage(s) from portal.dat"); + Fault(ContentEffectsAudioCompositionPoint.ChargenOptionsInstalled); + IAnimationLoader animations = _factory.CreateAnimationLoader( dats, _dependencies.ResidencyBudgets.AnimationBytes, diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index 95ade180..2d31ed23 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -539,7 +539,9 @@ internal sealed class FrameRootCompositionPhase renderFrameResources, new PrivateEntityViewportFrameGroup( live.PaperdollPresenter, - live.CreatureAppraisalPresenter), + live.CreatureAppraisalPresenter, + live.ChargenPreviewController, + live.SummaryPreviewController), retainedGameplayUi, // The ImGui developer-tools frontend was removed at Campaign V // slice V11; this optional hook is unbound until a follow-up @@ -581,7 +583,7 @@ internal sealed class FrameRootCompositionPhase var liveFrameCoordinator = new RetailLiveFrameCoordinator( session.LiveObjectFrame, live.WorldState, - session.LiveSession, + session.SessionHost, session.LocalPlayerFrame, session.LiveSpatialReconciler, live.WorldAvailability, diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 194eeb91..ec368803 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -21,10 +21,12 @@ using AcDream.Core.Selection; using AcDream.Core.Spells; using AcDream.Runtime; using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Session; using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Panels.Chat; using AcDream.UI.Abstractions.Panels.Vitals; using DatReaderWriter; +using DatReaderWriter.DBObjs; using Silk.NET.Input; using Silk.NET.Windowing; @@ -483,6 +485,12 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory d.DebugFont, d.HostQuiescence)); checkpoint(InteractionRetainedUiCompositionPoint.UiHostAcquired); + // AD-98 filtering fidelity: re-wired unconditionally on every + // composition, same as the UiLocked assignment below — the lease can + // hand back a HOST from a previous session while d.TextureCache is a + // fresh instance for this one, so a stale resolver would keep + // resolving twins against a disposed TextureCache. + host.TextRenderer.LinearTwinResolver = d.TextureCache.GetOrCreateLinearUiTwin; inputCapture = d.RetainedInputCapture.Bind(host.Root); checkpoint(InteractionRetainedUiCompositionPoint.InputCaptureBound); // D7 Group-C re-point (Campaign OP OP4, 2026-08-11): server @@ -636,6 +644,32 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory d.DebugFont, controls, iconComposer); + // Review fix round F12 (2026-08-15): constructed ONCE per + // composition and captured by the ResolveText closure below, + // rather than a fresh DatStringResolver per lookup. The + // Heritage/Town pages' description composers each call + // ResolveText several times per Refresh, and CharacterCreation- + // UiController.ApplyProgressState forces a full refresh on + // every page switch (`_lastRevision = long.MinValue`) — so an + // uncached resolver meant several fresh allocations + DatLock + // acquisitions per click. DatStringResolver's own constructor + // does no DAT I/O (only .Resolve reads), so building it here + // outside the lock matches this file's existing pattern + // elsewhere (construct once, lock only around Resolve calls). + var characterCreationStrings = new DatStringResolver(d.Dats); + // CC5 review fix round F3 (2026-08-16): read the global + // SkillTable (portal.dat 0x0E000004 — the SAME file + // ChargenOptions.GlobalSkillCostsBySkillId's own doc comment and + // LiveSessionRuntimeFactory.CreateCharacterBindings already read) + // ONCE at composition time, under the DatLock DatCollection's + // thread-safety contract requires — mirrors LiveSkillCreditResolver's + // own constructor-time load. The resolver itself does no further + // DAT access per call (pure SkillFormula arithmetic), so the + // Summary page's GetSkillScore binding below needs no lock. + SkillTable? chargenSkillTable; + lock (d.DatLock) + chargenSkillTable = d.Dats.Get(0x0E000004u); + var chargenSkillScoreResolver = new ChargenSkillScoreResolver(chargenSkillTable); var bindings = new RetailUiRuntimeBindings( Host: host, Assets: assets, @@ -937,7 +971,64 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory (action, held) => d.InputDispatcher?.TrySetAutomationActionHeld(action, held) == true, late.Automation), - Keyboard: new KeyboardRuntimeBindings(d.InputDispatcher, d.KeyBindingsFilePath)); + Keyboard: new KeyboardRuntimeBindings( + d.InputDispatcher, + d.KeyBindingsFilePath), + CharacterSelection: d.Options.LiveCharacterSelector is null + ? new CharacterSelectionRuntimeBindings( + () => late.GameRuntime.CharacterSelection, + late.GameRuntime.CharacterSelectionHighlight, + late.GameRuntime.CharacterSelectionEnter, + late.GameRuntime.CharacterSelectionRequestDelete, + late.GameRuntime.CharacterSelectionConfirmDelete, + late.GameRuntime.CharacterSelectionRestore, + late.GameRuntime.CharacterSelectionCancel, + // Campaign LA gate round 2 finding 1: the SAME + // window-close path GameplayInputCommandController's + // Escape fallback uses (IGameplayWindowCommands.Close + // /GameplayWindowCommands wrap this same d.Window.Close + // delegate) — no separate exit path. + d.Window.Close) + : null, + // Campaign CC slice CC4: same late-bound generation-capturing + // seam as CharacterSelection above. RequestExit here is a + // plain presentation action (closing the chargen screen and + // letting character-management's own Tick keep re-drawing + // itself underneath — see CharacterCreationUiController's + // OnExit doc), NOT a Runtime command or a window-close. + CharacterCreation: d.Options.LiveCharacterSelector is null + ? new CharacterCreationRuntimeBindings( + () => late.GameRuntime.CharacterCreation, + late.GameRuntime.CharacterCreationSelectHeritage, + late.GameRuntime.CharacterCreationSelectGender, + late.GameRuntime.CharacterCreationSelectTemplate, + late.GameRuntime.CharacterCreationSetAttribute, + late.GameRuntime.CharacterCreationSetAttributeLock, + late.GameRuntime.CharacterCreationTrainSkill, + late.GameRuntime.CharacterCreationSpecializeSkill, + late.GameRuntime.CharacterCreationUntrainSkill, + late.GameRuntime.CharacterCreationSelectStartArea, + late.GameRuntime.CharacterCreationFinish, + RequestExit: () => { }, + SetAppearanceIndex: late.GameRuntime.CharacterCreationSetAppearanceIndex, + SetShade: late.GameRuntime.CharacterCreationSetShade, + ResolveText: key => + { + lock (d.DatLock) + { + return characterCreationStrings.Resolve( + 0x23000002u, + DatStringResolver.ComputeHash(key)); + } + }, + SetName: late.GameRuntime.CharacterCreationSetName, + AcknowledgeRejection: late.GameRuntime.CharacterCreationAcknowledgeRejection, + RandomizeCharacter: late.GameRuntime.CharacterCreationRandomizeCharacter, + RandomizeAppearance: late.GameRuntime.CharacterCreationRandomizeAppearance, + RandomizeClothing: late.GameRuntime.CharacterCreationRandomizeClothing, + GetSkillScore: chargenSkillScoreResolver.Resolve, + OpenOnStart: d.Options.OpenCharacterCreationOnStart) + : null); RetailUiRuntime runtime = lease.Mount( () => RetailUiRuntime.CreateUninitialized(bindings)); checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted); diff --git a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs index 18ea229f..185032e5 100644 --- a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs +++ b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs @@ -10,6 +10,7 @@ using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Runtime; +using AcDream.Runtime.Session; using AcDream.UI.Abstractions; using Silk.NET.Windowing; @@ -37,6 +38,37 @@ internal sealed class DeferredGameRuntimeStateCommands } } + /// + /// Borrows the current adapter's character-selection projection. The + /// reference is deliberately not cached here: releasing or displacing the + /// exact late binding makes the next read return , + /// while CurrentGameRuntimeAdapter keeps an already-borrowed reference + /// inert if disposal races the render-thread consumer. + /// + public IRuntimeCharacterSelectionView? CharacterSelection + { + get + { + lock (_gate) + return !_deactivated && _view is not null + ? _view.CharacterSelection + : null; + } + } + + /// Campaign CC slice CC4: same late-bound borrow shape as + /// . + public IRuntimeCharacterCreationView? CharacterCreation + { + get + { + lock (_gate) + return !_deactivated && _view is not null + ? _view.CharacterCreation + : null; + } + } + public IDisposable Bind( IGameRuntimeView view, IGameRuntimeCommands commands) @@ -118,6 +150,119 @@ internal sealed class DeferredGameRuntimeStateCommands generation, new RuntimeAdvancementCommand(kind, statId, cost))); + // Campaign LA slice LA8: the retained character-management screen uses + // the same generation-capturing late seam as every gameplay panel. The + // screen never receives GameRuntime or WorldSession and cannot retain a + // stale generation across reconnect. + + public RuntimeCommandResult CharacterSelectionHighlight(uint characterId) => + Invoke((commands, generation) => + commands.CharacterSelection.Highlight(generation, characterId)); + + public RuntimeCommandResult CharacterSelectionEnter() => + Invoke((commands, generation) => + commands.CharacterSelection.Enter(generation)); + + public RuntimeCommandResult CharacterSelectionRequestDelete() => + Invoke((commands, generation) => + commands.CharacterSelection.RequestDelete(generation)); + + public RuntimeCommandResult CharacterSelectionConfirmDelete() => + Invoke((commands, generation) => + commands.CharacterSelection.ConfirmDelete(generation)); + + public RuntimeCommandResult CharacterSelectionRestore() => + Invoke((commands, generation) => + commands.CharacterSelection.Restore(generation)); + + public RuntimeCommandResult CharacterSelectionCancel() => + Invoke((commands, generation) => + commands.CharacterSelection.Cancel(generation)); + + // ── Campaign CC slice CC4: character-creation page commands ───────── + // Same "capture view+commands under one generation" shape as every + // character-selection method above. + + public RuntimeCommandResult CharacterCreationSelectHeritage(uint heritageId) => + Invoke((commands, generation) => + commands.CharacterCreation.SelectHeritage(generation, heritageId)); + + public RuntimeCommandResult CharacterCreationSelectGender(uint genderKey) => + Invoke((commands, generation) => + commands.CharacterCreation.SelectGender(generation, genderKey)); + + public RuntimeCommandResult CharacterCreationSelectTemplate(uint templateIndex) => + Invoke((commands, generation) => + commands.CharacterCreation.SelectTemplate(generation, templateIndex)); + + public RuntimeCommandResult CharacterCreationSetAttribute( + ChargenAttributeId attributeId, + int value) => + Invoke((commands, generation) => + commands.CharacterCreation.SetAttribute(generation, attributeId, value)); + + public RuntimeCommandResult CharacterCreationSetAttributeLock( + ChargenAttributeId attributeId, + bool locked) => + Invoke((commands, generation) => + commands.CharacterCreation.SetAttributeLock(generation, attributeId, locked)); + + public RuntimeCommandResult CharacterCreationTrainSkill(uint skillId) => + Invoke((commands, generation) => + commands.CharacterCreation.TrainSkill(generation, skillId)); + + public RuntimeCommandResult CharacterCreationSpecializeSkill(uint skillId) => + Invoke((commands, generation) => + commands.CharacterCreation.SpecializeSkill(generation, skillId)); + + public RuntimeCommandResult CharacterCreationUntrainSkill(uint skillId) => + Invoke((commands, generation) => + commands.CharacterCreation.UntrainSkill(generation, skillId)); + + public RuntimeCommandResult CharacterCreationSelectStartArea(int startAreaIndex) => + Invoke((commands, generation) => + commands.CharacterCreation.SelectStartArea(generation, startAreaIndex)); + + public RuntimeCommandResult CharacterCreationFinish(bool confirmUnspentCredits) => + Invoke((commands, generation) => + commands.CharacterCreation.Finish(generation, confirmUnspentCredits)); + + // ── Campaign CC slice CC6b-MOUNT: Appearance page commands ─────────── + + public RuntimeCommandResult CharacterCreationSetAppearanceIndex( + ChargenAppearanceSlot slot, + uint index) => + Invoke((commands, generation) => + commands.CharacterCreation.SetAppearanceIndex(generation, slot, index)); + + public RuntimeCommandResult CharacterCreationSetShade( + ChargenShadeSlot slot, + double value) => + Invoke((commands, generation) => + commands.CharacterCreation.SetShade(generation, slot, value)); + + // ── Campaign CC slice CC5: Summary page + RandomizeCharacter commands ── + + public RuntimeCommandResult CharacterCreationSetName(string name) => + Invoke((commands, generation) => + commands.CharacterCreation.SetName(generation, name)); + + public RuntimeCommandResult CharacterCreationAcknowledgeRejection() => + Invoke((commands, generation) => + commands.CharacterCreation.AcknowledgeRejection(generation)); + + public RuntimeCommandResult CharacterCreationRandomizeCharacter() => + Invoke((commands, generation) => + commands.CharacterCreation.RandomizeCharacter(generation)); + + public RuntimeCommandResult CharacterCreationRandomizeAppearance() => + Invoke((commands, generation) => + commands.CharacterCreation.RandomizeAppearance(generation)); + + public RuntimeCommandResult CharacterCreationRandomizeClothing() => + Invoke((commands, generation) => + commands.CharacterCreation.RandomizeClothing(generation)); + // ── Campaign FA slice FA4: fellowship page commands ───────────────── // Same "capture view+commands under one generation" shape as every // method above — a displaced session (reconnect mid-click) can never diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index 6c481669..60246361 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -125,6 +125,18 @@ internal sealed record LivePresentationResult( PaperdollFramePresenter? PaperdollPresenter, CreatureAppraisalViewportRenderer? CreatureAppraisalRenderer, CreatureAppraisalFramePresenter? CreatureAppraisalPresenter, + // Campaign CC slice CC6b-MOUNT: the chargen Appearance-page preview — + // the renderer (leased/disposed) and the controller (per-frame owner + + // late-bound zoom/rotate control surface) are separate fields because + // ChargenPreviewController does not own the renderer's lifetime (it is + // a leased composition resource, mirroring PaperdollViewportRenderer). + ChargenPreviewRenderer? ChargenPreviewRenderer, + ChargenPreviewController? ChargenPreviewController, + // Campaign CC slice CC5: the Summary page's own gmCG3DView instance — + // a SEPARATE leased renderer/controller pair, same split reasoning as + // the Appearance preview fields immediately above. + ChargenPreviewRenderer? SummaryPreviewRenderer, + ChargenPreviewController? SummaryPreviewController, WbFrustum EnvCellFrustum, EnvCellRenderer? EnvCellRenderer, LandblockPresentationPipeline LandblockPipeline, @@ -985,6 +997,235 @@ internal sealed class LivePresentationCompositionPhase new RetailCreatureAppraisalCloneFactory( new LiveCreatureAppraisalEntityLookup(liveEntities))); } + + // Campaign CC slice CC6b-MOUNT: the chargen Appearance-page preview. + // Same "both arms exist, needs a dispatcher + the retained-UI + // viewport widget" shape as paperdoll/creature-appraisal above — + // this is the THIRD private creature viewport, not a new pattern. + // + // Fix round F8 disposition: unlike paperdoll's PaperdollViewportWidget + // (an eager, non-retryable auto-property — see that property's own + // corrected doc comment), ChargenPreviewViewportWidget is + // computed-through a coordinator (CharacterCreationUiMountCoordinator) + // that IS explicitly retryable/idempotent across frames. This + // composition pass itself runs EXACTLY ONCE, synchronously, inside + // GameWindow.OnLoad — if the coordinator's mount hasn't succeeded + // yet at this exact instant, this block is skipped and NEVER + // retried; the coordinator's own later per-frame retries (driven + // from RetailUiRuntime.Tick) can still complete the CONTROLLER mount + // afterward, but this GPU-side renderer/viewport binding will not + // pick that up. DECIDED at the review: this composition pass is a + // one-shot GPU-resource wiring step (matching paperdoll's and + // creature-appraisal's own one-shot binding in this exact method, + // and PublishLivePresentation's own "set exactly once" invariant a + // few hundred lines below) — retrofitting cross-frame retry here + // would mean restructuring this whole composition's one-shot + // contract (and the fixed PrivateEntityViewportFrameGroup array + // FrameRootComposition builds from its result) for every private + // viewport, not just this one; that is out of this fix round's + // blast radius. What changes here instead: a loud diagnostic + // instead of a silent skip, so an operator can SEE the preview + // failed to bind this session rather than the symptom (dead + // zoom/rotate buttons) reading as unexplained. + CompositionAcquisitionScope.CompositionAcquisitionLease< + ChargenPreviewRenderer>? chargenPreviewLease = null; + ChargenPreviewController? chargenPreviewController = null; + if (dispatcherLease.Resource is { } chargenDispatcher + && interaction.RetainedUi?.Runtime.ChargenPreviewViewportWidget is { } chargenViewport) + { + var chargenCamera = new ChargenPreviewCamera(); + chargenPreviewLease = scope.Acquire( + "chargen preview viewport", + () => new ChargenPreviewRenderer( + worldPassScope + ?? throw new InvalidOperationException( + "The graphics backend must publish a world pass scope."), + host.GpuDevice, + host.GpuFrameLifetime, + chargenDispatcher, + foundation.SceneLighting!, + foundation.TextureCache, + foundation.MeshAdapter!, + camera: chargenCamera), + static value => value.Dispose()); + IUiViewportRenderer? previousChargenRenderer = chargenViewport.Renderer; + chargenViewport.Renderer = chargenPreviewLease.Resource; + bindings.AdoptRelease( + "chargen preview viewport target", + () => + { + if (ReferenceEquals(chargenViewport.Renderer, chargenPreviewLease.Resource)) + chargenViewport.Renderer = previousChargenRenderer; + }); + + var chargenCatalog = new AcDream.Content.CharGen.ChargenAppearanceCatalog(content.Dats); + chargenPreviewController = new ChargenPreviewController( + chargenPreviewLease.Resource, + chargenCamera, + new RetailChargenPreviewFrameView( + chargenViewport, + new RetailChargenPreviewPageVisibility(interaction.RetainedUi.Runtime)), + content.Dats, + content.AnimationLoader, + chargenCatalog, + chargenCatalog, + d.DatLock); + interaction.RetainedUi.Runtime.ChargenPreviewControl = chargenPreviewController; + // Campaign CC gate round 1 closeout (Group 1, R2-5): the two + // Batch G STOPPED items land here — chargenCatalog already + // implements all three color-wheel seams (TryGetPalSet/ + // TryGetClothingTable/TryGetColor), same instance as the + // preview control just above, same one-shot composition-time + // assignment. + interaction.RetainedUi.Runtime.ChargenPalSetSource = chargenCatalog; + interaction.RetainedUi.Runtime.ChargenClothingTableSource = chargenCatalog; + interaction.RetainedUi.Runtime.ChargenPaletteColorSource = chargenCatalog; + // R3-5/R3-6 (Campaign CC gate round 1 re-test 2): the fourth + // seam — needs a TextureCache (foundation.TextureCache, already + // acquired above for the preview renderer), so it is its own + // composer rather than folded into chargenCatalog (a pure + // Content-layer DAT reader with no GL/backend dependency). + var chargenSwatchTextures = new AcDream.App.UI.Layout.ChargenColorSpotComposer( + content.Dats, foundation.TextureCache); + interaction.RetainedUi.Runtime.ChargenSwatchTextureSource = chargenSwatchTextures; + bindings.AdoptRelease( + "chargen preview control", + () => + { + if (ReferenceEquals( + interaction.RetainedUi.Runtime.ChargenPreviewControl, + chargenPreviewController)) + { + interaction.RetainedUi.Runtime.ChargenPreviewControl = null; + } + }); + } + else if (dispatcherLease.Resource is not null && interaction.RetainedUi is not null) + { + // Fix round F8: dispatcher is available but the mount coordinator + // hadn't resolved ChargenPreviewViewportWidget by this one-shot + // pass — loud instead of silent, since the coordinator's own + // later per-frame retries cannot recover this GPU-side binding + // (see this block's own disposition comment above). + // + // Re-review R1: the retained UI arm (`interaction.RetainedUi`) + // is null in the default configuration (ACDREAM_RETAIL_UI + // unset — see InteractionRetainedUiComposition.cs's own gate on + // RuntimeOptions.RetailUi), and in that configuration there is + // no Appearance page at all. The dispatcher lease is + // acquired unconditionally regardless of retained-UI presence, + // so without this second guard every ordinary launch printed + // this diagnostic even though nothing was actually broken. + // Narrowed to fire only in the one configuration it is meant to + // diagnose: retained UI mounted, dispatcher ready, but the + // coordinator's widget resolution missed this one-shot pass. + Console.WriteLine( + "[UI] chargen preview viewport unavailable at composition " + + "time — the Appearance page's zoom/rotate controls and " + + "3D preview will not function this session."); + } + + // Campaign CC slice CC5: the Summary page's OWN gmCG3DView instance + // (gmCGSummaryPage::InitializePage @0x0047bbf0, confirmed a SEPARATE + // instance from the Appearance page's own during the CC6b-MOUNT + // review) — same one-shot binding shape as the Appearance preview + // immediately above. Review fix round F7 (2026-08-16): AP-221 is now + // AMENDED to cover this second binding explicitly (it originally + // named CC5 as the slice that should CLOSE the gap; CC5 duplicated + // the pattern here instead) — a DAT/resource read not ready on this + // exact composition frame means the Summary preview stays + // permanently unbound for the session, same tracked follow-up as + // the Appearance preview, now under the same amended row. No + // zoom/rotate control surface is wired — retail's Summary page has + // no such buttons (only StartAnimation's idle loop and a + // fixed 180° heading), so this controller's ZoomIn/RotateClockwise + // etc. simply never get called. + CompositionAcquisitionScope.CompositionAcquisitionLease< + ChargenPreviewRenderer>? summaryPreviewLease = null; + ChargenPreviewController? summaryPreviewController = null; + if (dispatcherLease.Resource is { } summaryDispatcher + && interaction.RetainedUi?.Runtime.SummaryPreviewViewportWidget is { } summaryViewport) + { + var summaryCamera = new ChargenPreviewCamera(); + summaryPreviewLease = scope.Acquire( + "summary preview viewport", + () => new ChargenPreviewRenderer( + worldPassScope + ?? throw new InvalidOperationException( + "The graphics backend must publish a world pass scope."), + host.GpuDevice, + host.GpuFrameLifetime, + summaryDispatcher, + foundation.SceneLighting!, + foundation.TextureCache, + foundation.MeshAdapter!, + camera: summaryCamera, + // F16 (Campaign CC gate round 1 closeout): the Summary + // page's OWN render-id pair — see + // ChargenPreviewEntityBuilder.SummaryPreviewRenderId's + // own doc for why sharing the Appearance page's pair + // (the pre-existing default) is a real cross-page + // texture-release collision, not merely untidy, since + // both pages share the SAME foundation.TextureCache + // passed one line above. + renderId: AcDream.App.Rendering.ChargenPreviewEntityBuilder.SummaryPreviewRenderId, + backdropRenderId: AcDream.App.Rendering.ChargenPreviewEntityBuilder.SummaryPreviewBackdropRenderId), + static value => value.Dispose()); + IUiViewportRenderer? previousSummaryRenderer = summaryViewport.Renderer; + summaryViewport.Renderer = summaryPreviewLease.Resource; + bindings.AdoptRelease( + "summary preview viewport target", + () => + { + if (ReferenceEquals(summaryViewport.Renderer, summaryPreviewLease.Resource)) + summaryViewport.Renderer = previousSummaryRenderer; + }); + + var summaryCatalog = new AcDream.Content.CharGen.ChargenAppearanceCatalog(content.Dats); + summaryPreviewController = new ChargenPreviewController( + summaryPreviewLease.Resource, + summaryCamera, + new RetailChargenPreviewFrameView( + summaryViewport, + new RetailSummaryPreviewPageVisibility(interaction.RetainedUi.Runtime)), + content.Dats, + content.AnimationLoader, + summaryCatalog, + summaryCatalog, + d.DatLock, + // F5 (2026-08-16): the Summary preview is retail's zoomed- + // OUT full-body framing (gmCGSummaryPage::InitializePage @ + // 0x0047bbf0), not the Appearance page's zoomed-in default — + // see ChargenPreviewController's own ctor doc comment. + useZoomedOutEye: true, + // F16 (Campaign CC gate round 1 closeout): MUST match the + // renderId/backdropRenderId pair given to summaryPreviewLease's + // own ChargenPreviewRenderer above — see + // ChargenPreviewEntityBuilder.SummaryPreviewRenderId's own + // doc for why sharing the Appearance page's pair here would + // be a real cross-page TextureCache collision. + renderId: AcDream.App.Rendering.ChargenPreviewEntityBuilder.SummaryPreviewRenderId, + backdropRenderId: AcDream.App.Rendering.ChargenPreviewEntityBuilder.SummaryPreviewBackdropRenderId); + interaction.RetainedUi.Runtime.SummaryPreviewControl = summaryPreviewController; + bindings.AdoptRelease( + "summary preview control", + () => + { + if (ReferenceEquals( + interaction.RetainedUi.Runtime.SummaryPreviewControl, + summaryPreviewController)) + { + interaction.RetainedUi.Runtime.SummaryPreviewControl = null; + } + }); + } + else if (dispatcherLease.Resource is not null && interaction.RetainedUi is not null) + { + Console.WriteLine( + "[UI] summary preview viewport unavailable at composition " + + "time — the Summary page's 3D preview will not function " + + "this session."); + } Fault(LivePresentationCompositionPoint.PrivateCreatureViewportsCreated); var envCellFrustum = new WbFrustum(); @@ -1291,6 +1532,10 @@ internal sealed class LivePresentationCompositionPhase paperdollPresenter, creatureAppraisalLease?.Resource, creatureAppraisalPresenter, + chargenPreviewLease?.Resource, + chargenPreviewController, + summaryPreviewLease?.Resource, + summaryPreviewController, envCellFrustum, envCellLease.Resource, landblockPipeline, @@ -1325,6 +1570,17 @@ internal sealed class LivePresentationCompositionPhase retainedGameplayLease?.Transfer(); paperdollLease?.Transfer(); creatureAppraisalLease?.Transfer(); + // #405: these two Transfer calls were MISSING from CC6b-MOUNT (chargen) + // and CC5 (summary) — both leases rode into the published result at + // the paperdoll siblings' positions above, but without the Transfer + // the scope's unpublished-resource leak guard threw on every real + // window load ("Composition phase completed with unpublished + // resources: chargen preview viewport, summary preview viewport"), + // killing the client at startup whenever retail UI mounted the + // chargen screen. No automated suite executes this ladder (it needs + // a live GPU window), which is how five review rounds read past it. + chargenPreviewLease?.Transfer(); + summaryPreviewLease?.Transfer(); envCellLease.Transfer(); clipFrameLease.Transfer(); portalDepthLease.Transfer(); diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 769203ef..b4a214bd 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -82,7 +82,11 @@ internal sealed record SessionPlayerDependencies( CombatAttackOperationsSlot CombatAttackOperations, CombatFeedbackSlot CombatFeedback, TransferableResourceSlot PortalTunnelFallback, - Action Log) + Action Log, + /// Campaign LA slice LA1: the shared per-session status-event + /// writer, no-op when was + /// not configured. + SessionStatusWriter StatusWriter) { public RuntimeActionState Actions => Runtime.ActionOwner; @@ -1124,7 +1128,11 @@ internal sealed class SessionPlayerCompositionPhase acceptedPositionDrive, remotePlacementDrive), liveSessionCommands, - d.Log); + d.Log, + d.StatusWriter, + d.Options.SessionId ?? "app", + d.Options.LoginCommands, + d.Options.LoginCommandDelayMs); LiveSessionHost sessionHost = sessionRuntimeFactory.Create( liveSession, new LiveSessionConnectOptions( @@ -1132,7 +1140,10 @@ internal sealed class SessionPlayerCompositionPhase d.Options.LiveHost, d.Options.LivePort, d.Options.LiveUser ?? string.Empty, - d.Options.LivePass ?? string.Empty)); + d.Options.LivePass ?? string.Empty, + d.Options.LiveCharacterSelector, + AwaitCharacterSelection: + d.Options.LiveCharacterSelector is null)); Fault(SessionPlayerCompositionPoint.SessionHostCreated); // The ImGui developer-tools debug toast sink was removed at Campaign V diff --git a/src/AcDream.App/Composition/SessionStartComposition.cs b/src/AcDream.App/Composition/SessionStartComposition.cs index fa352db9..64e423dc 100644 --- a/src/AcDream.App/Composition/SessionStartComposition.cs +++ b/src/AcDream.App/Composition/SessionStartComposition.cs @@ -1,4 +1,5 @@ using AcDream.Runtime; +using AcDream.Runtime.Session; namespace AcDream.App.Composition; diff --git a/src/AcDream.App/Configuration/SessionConfigArgumentParsing.cs b/src/AcDream.App/Configuration/SessionConfigArgumentParsing.cs new file mode 100644 index 00000000..96d2f43c --- /dev/null +++ b/src/AcDream.App/Configuration/SessionConfigArgumentParsing.cs @@ -0,0 +1,73 @@ +namespace AcDream.App.Configuration; + +/// +/// Campaign LA slice LA1 review fix (F5): extracted from Program.cs's +/// top-level-statement local functions so the trailing-flag edge case is +/// unit testable — a top-level program's local functions are compiler- +/// synthesized private members of the generated Program class with +/// no stable surface a test assembly can reach. +/// +internal static class SessionConfigArgumentParsing +{ + /// + /// Finds in and + /// returns its value. Three distinct outcomes, distinguished by + /// and the return value together: + /// + /// flag absent: = , + /// returns — the caller's env-var/positional + /// fallback stays in effect, unchanged from before this flag + /// existed. + /// flag present with a following value: + /// = , returns that value. + /// flag present but is the LAST argument, with nothing after it: + /// = , returns + /// — the caller MUST treat this as a hard error + /// (the flag was typed but its value was not), never silently fall + /// through to the flag-absent path. + /// + /// + internal static string? ExtractFlagValue( + string[] arguments, + string flag, + out bool present) + { + ArgumentNullException.ThrowIfNull(arguments); + ArgumentException.ThrowIfNullOrWhiteSpace(flag); + + for (int i = 0; i < arguments.Length; i++) + { + if (!string.Equals(arguments[i], flag, StringComparison.Ordinal)) + continue; + + present = true; + return i == arguments.Length - 1 ? null : arguments[i + 1]; + } + + present = false; + return null; + } + + /// Returns with + /// and its following value (if any) removed. A trailing, valueless flag + /// is dropped on its own — this helper only strips arguments, it does + /// not decide whether a trailing flag is an error (see + /// 's present output for that). + internal static string[] WithoutFlagAndValue(string[] arguments, string flag) + { + ArgumentNullException.ThrowIfNull(arguments); + ArgumentException.ThrowIfNullOrWhiteSpace(flag); + + var result = new List(arguments.Length); + for (int i = 0; i < arguments.Length; i++) + { + if (string.Equals(arguments[i], flag, StringComparison.Ordinal)) + { + i++; // also skip the flag's value, if any + continue; + } + result.Add(arguments[i]); + } + return [.. result]; + } +} diff --git a/src/AcDream.App/Configuration/SessionConfiguration.cs b/src/AcDream.App/Configuration/SessionConfiguration.cs new file mode 100644 index 00000000..ec225785 --- /dev/null +++ b/src/AcDream.App/Configuration/SessionConfiguration.cs @@ -0,0 +1,175 @@ +using System.Text.Json.Serialization; + +namespace AcDream.App.Configuration; + +/// +/// Campaign LA slice LA1: the graphical host's reader for the pinned +/// session-config document shape shared with +/// AcDream.Headless.Configuration.HeadlessConfiguration — see +/// docs/plans/2026-08-14-launcher-campaign.md LA1 and +/// docs/superpowers/specs/2026-08-14-launcher-campaign-design.md §6. +/// +/// +/// This is a DELIBERATELY independent DTO set, not a shared type reused from +/// AcDream.Headless — Headless's config types are internal, tied to +/// its own OP7 characterOptions allow-list semantics, and Headless is +/// not a project App references. The two readers are cross-checked instead +/// by a shared fixture document both test suites parse +/// (SessionConfigurationSharedFixtureTests / +/// HeadlessConfigurationSharedFixtureTests). +/// +/// +/// +/// Differences from the Headless reader, all intentional per the pinned +/// contract: is OPTIONAL here +/// (absent = today's first-available fallback; the character-select screen +/// is LA7, not this slice); is parsed +/// but never consulted (App has no bot-policy concept); exactly ONE session +/// is required, not "one or more". +/// +/// +internal sealed class SessionConfiguration +{ + [JsonRequired] + public int Version { get; init; } + + public SessionProcessSettings? Process { get; init; } + + [JsonRequired] + public List Sessions { get; init; } = []; +} + +internal sealed class SessionProcessSettings +{ + public SessionContentDescriptor? Content { get; init; } + + /// Campaign LA slice LA1 review fix (F2): accepted so the SAME + /// document also satisfies the Headless loader's own + /// process.paths member (HeadlessPathOverrides) — parsed + /// and ignored here, exactly like + /// and below. App has + /// no config/data/cache directory override concept of its own (those + /// come from ApplicationPathSet/env vars on this host); only the + /// Headless host consumes overrides composed under this key. + public SessionProcessPathOverrides? Paths { get; init; } +} + +/// Accepted-but-ignored mirror of Headless's +/// HeadlessPathOverrides shape — see +/// . +internal sealed class SessionProcessPathOverrides +{ + public string? ConfigDirectory { get; init; } + public string? DataDirectory { get; init; } + public string? CacheDirectory { get; init; } +} + +internal sealed class SessionContentDescriptor +{ + [JsonRequired] + public string DatDirectory { get; init; } = string.Empty; + + [JsonRequired] + public string PreparedAssetPath { get; init; } = string.Empty; +} + +internal sealed record SessionDescriptor +{ + [JsonRequired] + public string Id { get; init; } = string.Empty; + + [JsonRequired] + public SessionEndpointDescriptor Endpoint { get; init; } = new(); + + [JsonRequired] + public string Account { get; init; } = string.Empty; + + /// Optional for the graphical host: absent means today's + /// existing first-available fallback stays in effect. The retail + /// character-select screen (LA7) is what actually consumes "no + /// selector" as "stop and let the user pick". + public SessionCharacterSelectorDescriptor? Character { get; init; } + + /// Accepted so the SAME document also satisfies the Headless + /// loader's JsonRequired policy field — parsed and ignored here; + /// App has no bot-policy concept. + public SessionPolicyDescriptor? Policy { get; init; } + + /// Campaign LA slice LA1 review fix (F2): pinned-contract + /// mode discriminator. ABSENT means today's ONLY App behavior — an + /// ordinary play session — so every document written before this field + /// existed keeps parsing unchanged. "probe" (LA2's connect + /// ▸ characterList ▸ graceful-disconnect flow, no EnterWorld) is + /// HEADLESS-ONLY; the App loader rejects it with an explicit message + /// naming the field rather than the caller ever seeing a raw unmapped- + /// member . Any other value + /// is a configuration error — the pinned contract defines no other + /// mode literal, so a document is either silent about mode (play) or + /// says "probe" exactly. + public string? Mode { get; init; } + + [JsonRequired] + public SessionCredentialDescriptor Credential { get; init; } = new(); + + /// Accepted-but-ignored by App; Headless's own loader owns the + /// allow-list semantics for this field (OP7 D8). + public Dictionary? CharacterOptions { get; init; } + + /// LA1/LA5: plugin ids to load. Absent = load all; explicit + /// empty = load none. + public List? Plugins { get; init; } + + /// LA1/LA6: ordered chat-typed strings run through the shared + /// Runtime parser/router after entering world. + public List? LoginCommands { get; init; } + + /// LA1: inter-command delay for , + /// milliseconds. Matches the pinned contract default of 500 ms. + public int LoginCommandDelayMs { get; init; } = 500; + + /// LA1: absolute path for the status-event JSONL stream. + /// Absent = no writer constructed. + public string? StatusFile { get; init; } +} + +internal sealed class SessionEndpointDescriptor +{ + [JsonRequired] + public string Host { get; init; } = string.Empty; + + [JsonRequired] + public int Port { get; init; } +} + +internal sealed class SessionCharacterSelectorDescriptor +{ + public int? Index { get; init; } + public uint? Id { get; init; } + public string? Name { get; init; } +} + +/// Loose by design: App never inspects the policy's shape beyond +/// "does this document parse" — Id/Role stay untyped strings so +/// this DTO never has to track Headless's own policy-id/role vocabulary. +internal sealed class SessionPolicyDescriptor +{ + public string? Id { get; init; } + public string? Role { get; init; } +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum SessionCredentialProviderKind +{ + Environment, + StandardInput, + File, +} + +internal sealed class SessionCredentialDescriptor +{ + [JsonRequired] + public SessionCredentialProviderKind Provider { get; init; } + + [JsonRequired] + public string Reference { get; init; } = string.Empty; +} diff --git a/src/AcDream.App/Configuration/SessionConfigurationException.cs b/src/AcDream.App/Configuration/SessionConfigurationException.cs new file mode 100644 index 00000000..05ca315f --- /dev/null +++ b/src/AcDream.App/Configuration/SessionConfigurationException.cs @@ -0,0 +1,18 @@ +namespace AcDream.App.Configuration; + +/// Mirrors AcDream.Headless.Configuration.HeadlessConfigurationException +/// — a semantic validation failure of an already well-typed session-config +/// document (a type-SHAPE violation fails earlier, as a raw +/// during deserialization). +internal sealed class SessionConfigurationException : Exception +{ + internal SessionConfigurationException(string message) + : base(message) + { + } + + internal SessionConfigurationException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/AcDream.App/Configuration/SessionConfigurationLoader.cs b/src/AcDream.App/Configuration/SessionConfigurationLoader.cs new file mode 100644 index 00000000..086cb11e --- /dev/null +++ b/src/AcDream.App/Configuration/SessionConfigurationLoader.cs @@ -0,0 +1,184 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AcDream.App.Configuration; + +/// +/// Campaign LA slice LA1: loads and validates the --session-config +/// document for the graphical host. Same strictness as +/// AcDream.Headless.Configuration.HeadlessConfigurationLoader +/// (camelCase, , camelCase +/// string enums) — see that type's own doc for why the two readers are +/// independent DTOs rather than a shared type. +/// +internal static class SessionConfigurationLoader +{ + private const int CurrentVersion = 1; + + private static readonly JsonSerializerOptions Options = new() + { + AllowTrailingCommas = false, + PropertyNameCaseInsensitive = false, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + ReadCommentHandling = JsonCommentHandling.Disallow, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + Converters = + { + new JsonStringEnumConverter( + JsonNamingPolicy.CamelCase, + allowIntegerValues: false), + }, + }; + + /// Loads the document and returns the exact one configured + /// the graphical host runs — the + /// document itself may only ever declare exactly one session. + internal static (SessionConfiguration Configuration, SessionDescriptor Session) Load( + string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + string fullPath = Path.GetFullPath(path); + using FileStream stream = File.OpenRead(fullPath); + SessionConfiguration? configuration = + JsonSerializer.Deserialize(stream, Options); + + if (configuration is null) + { + throw new SessionConfigurationException( + "The configuration document is empty."); + } + + if (configuration.Version != CurrentVersion) + { + throw new SessionConfigurationException( + $"Unsupported configuration version {configuration.Version}; " + + $"expected {CurrentVersion}."); + } + + if (configuration.Sessions is null + || configuration.Sessions.Count != 1) + { + throw new SessionConfigurationException( + "The graphical host requires exactly one configured session."); + } + + SessionDescriptor session = configuration.Sessions[0] + ?? throw new SessionConfigurationException( + "The configured session cannot be null."); + + ValidateContent(configuration.Process?.Content); + ValidateSession(session); + + return (configuration, session); + } + + private static void ValidateContent(SessionContentDescriptor? content) + { + if (content is null) + return; + if (string.IsNullOrWhiteSpace(content.DatDirectory) + || string.IsNullOrWhiteSpace(content.PreparedAssetPath)) + { + throw new SessionConfigurationException( + "process.content requires non-empty datDirectory and preparedAssetPath."); + } + } + + private static void ValidateSession(SessionDescriptor session) + { + if (string.IsNullOrWhiteSpace(session.Id)) + { + throw new SessionConfigurationException( + "The session requires a non-empty id."); + } + + if (session.Endpoint is null + || string.IsNullOrWhiteSpace(session.Endpoint.Host) + || session.Endpoint.Port is < 1 or > 65535) + { + throw new SessionConfigurationException( + $"Session '{session.Id}' requires a host and a port from 1 through 65535."); + } + + if (string.IsNullOrWhiteSpace(session.Account)) + { + throw new SessionConfigurationException( + $"Session '{session.Id}' requires a non-empty account."); + } + + if (session.Character is { } selector) + { + int selectorCount = + (selector.Index.HasValue ? 1 : 0) + + (selector.Id.HasValue ? 1 : 0) + + (!string.IsNullOrWhiteSpace(selector.Name) ? 1 : 0); + if (selectorCount != 1 + || selector.Index is < 0 + || selector.Id == 0u) + { + throw new SessionConfigurationException( + $"Session '{session.Id}' character selector must specify " + + "exactly one valid index, id, or name."); + } + } + + if (session.Credential is null + || string.IsNullOrWhiteSpace(session.Credential.Reference)) + { + throw new SessionConfigurationException( + $"Session '{session.Id}' requires a credential reference."); + } + + if (session.Plugins is { } plugins) + { + foreach (string? plugin in plugins) + { + if (string.IsNullOrWhiteSpace(plugin)) + { + throw new SessionConfigurationException( + $"Session '{session.Id}' plugins entries must be non-empty strings."); + } + } + } + + if (session.LoginCommandDelayMs < 0) + { + throw new SessionConfigurationException( + $"Session '{session.Id}' loginCommandDelayMs must be non-negative."); + } + + if (session.StatusFile is not null + && string.IsNullOrWhiteSpace(session.StatusFile)) + { + throw new SessionConfigurationException( + $"Session '{session.Id}' statusFile must be a non-empty path when present."); + } + + ValidateMode(session); + } + + /// + /// Campaign LA slice LA1 review fix (F2): mode is Headless-only + /// on the App host — the graphical host has no probe concept (LA2 + /// builds the probe in Headless only). An absent field is today's ONLY + /// App behavior (play); "probe" gets a specific, actionable + /// message instead of a cryptic unmapped-member JSON error; anything + /// else is a plain configuration error. + /// + private static void ValidateMode(SessionDescriptor session) + { + if (session.Mode is null) + return; + + if (string.Equals(session.Mode, "probe", StringComparison.Ordinal)) + { + throw new SessionConfigurationException( + $"Session '{session.Id}' has mode 'probe'; probe sessions " + + "are headless-only and cannot run on the graphical host."); + } + + throw new SessionConfigurationException( + $"Session '{session.Id}' has unsupported mode '{session.Mode}'."); + } +} diff --git a/src/AcDream.App/Credentials/AppCredentialResolver.cs b/src/AcDream.App/Credentials/AppCredentialResolver.cs new file mode 100644 index 00000000..a311b77e --- /dev/null +++ b/src/AcDream.App/Credentials/AppCredentialResolver.cs @@ -0,0 +1,156 @@ +using AcDream.App.Configuration; +using AcDream.App.Platform; + +namespace AcDream.App.Credentials; + +/// +/// Campaign LA slice LA1: resolves a --session-config session's +/// credential reference — the App-side mirror of +/// AcDream.Headless.Credentials.HeadlessCredentialResolver (see that +/// type's own file for why this is an independent port rather than a shared +/// reference). Supports the same three providers with the same semantics: +/// environment (read an env var), standardInput (read one line +/// from stdin, mirroring HeadlessCredentialResolver.ResolveStandardInput), +/// and file (read a credential file relative to a base directory, +/// rejecting symlinks and, on Linux, group/other-readable permissions). +/// +internal sealed class AppCredentialResolver +{ + private const UnixFileMode NonUserPermissionMask = + UnixFileMode.GroupRead + | UnixFileMode.GroupWrite + | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead + | UnixFileMode.OtherWrite + | UnixFileMode.OtherExecute; + + private readonly TextReader _standardInput; + private readonly string _credentialBaseDirectory; + private readonly bool _isLinux; + + /// + /// is the caller-supplied platform-policy + /// value from GraphicalHostPlatformServices. This file still uses + /// RuntimePlatformGuard.IsLinuxRuntime below as the narrow + /// CA1416-recognized runtime guard required before calling + /// File.GetUnixFileMode; it does not independently select the host + /// platform or bypass the platform-services owner. + /// + internal AppCredentialResolver( + TextReader standardInput, + string credentialBaseDirectory, + bool isLinux) + { + _standardInput = standardInput + ?? throw new ArgumentNullException(nameof(standardInput)); + ArgumentException.ThrowIfNullOrWhiteSpace(credentialBaseDirectory); + _credentialBaseDirectory = Path.GetFullPath(credentialBaseDirectory); + _isLinux = isLinux; + } + + internal AppCredentialSecret Resolve( + string sessionId, + SessionCredentialDescriptor credential) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentNullException.ThrowIfNull(credential); + + string value; + try + { + value = credential.Provider switch + { + SessionCredentialProviderKind.Environment => + ResolveEnvironment(credential.Reference), + SessionCredentialProviderKind.StandardInput => + ResolveStandardInput(credential.Reference), + SessionCredentialProviderKind.File => + ResolveFile(credential.Reference), + _ => throw new AppCredentialException( + $"Session '{sessionId}' uses an unsupported credential provider."), + }; + } + catch (AppCredentialException) + { + throw; + } + catch (Exception error) + when (error is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException) + { + throw new AppCredentialException( + $"Credential '{credential.Reference}' for session '{sessionId}' could not be resolved.", + error); + } + + try + { + return new AppCredentialSecret(credential.Reference, value.AsSpan()); + } + finally + { + // The BCL returns immutable strings from environment, TextReader, + // and File APIs. Do not retain another copy in the resolver; the + // erasable char[] owner becomes the sole explicit retained copy. + value = string.Empty; + } + } + + private static string ResolveEnvironment(string reference) + { + string? value = Environment.GetEnvironmentVariable(reference); + if (string.IsNullOrEmpty(value)) + { + throw new AppCredentialException( + $"Credential environment reference '{reference}' is unavailable."); + } + return value; + } + + private string ResolveStandardInput(string reference) + { + string? value = _standardInput.ReadLine(); + if (string.IsNullOrEmpty(value)) + { + throw new AppCredentialException( + $"Credential standard-input reference '{reference}' is unavailable."); + } + return value; + } + + private string ResolveFile(string reference) + { + string path = Path.GetFullPath(reference, _credentialBaseDirectory); + var file = new FileInfo(path); + if (file.LinkTarget is not null) + { + throw new AppCredentialException( + $"Credential file reference '{reference}' cannot be a symbolic link."); + } + + // RuntimePlatformGuard.IsLinuxRuntime is the CA1416-recognized guard + // for File.GetUnixFileMode below; _isLinux is the separate, + // caller-injected value tests use for deterministic cross-platform + // coverage (see the constructor's own doc). + if (RuntimePlatformGuard.IsLinuxRuntime && _isLinux) + { + UnixFileMode mode = File.GetUnixFileMode(path); + if ((mode & NonUserPermissionMask) != 0 + || (mode & UnixFileMode.UserRead) == 0) + { + throw new AppCredentialException( + $"Credential file reference '{reference}' must be readable only by its owner."); + } + } + + string value = File.ReadAllText(path).TrimEnd('\r', '\n'); + if (value.Length == 0) + { + throw new AppCredentialException( + $"Credential file reference '{reference}' is empty."); + } + return value; + } +} diff --git a/src/AcDream.App/Credentials/AppCredentialSecret.cs b/src/AcDream.App/Credentials/AppCredentialSecret.cs new file mode 100644 index 00000000..a251f0ed --- /dev/null +++ b/src/AcDream.App/Credentials/AppCredentialSecret.cs @@ -0,0 +1,65 @@ +using System.Security.Cryptography; + +namespace AcDream.App.Credentials; + +/// +/// Campaign LA slice LA1: retains a resolved --session-config +/// credential in erasable memory — the App-side mirror of +/// AcDream.Headless.Credentials.HeadlessCredentialSecret (that type is +/// internal to the Headless project, so this is a minimal, independent port +/// rather than a shared reference). The network boundary still requires one +/// short-lived immutable string; callers must not retain that value beyond +/// constructing the connect request. +/// +internal sealed class AppCredentialSecret : IDisposable +{ + private char[]? _buffer; + + internal AppCredentialSecret(string referenceId, ReadOnlySpan value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(referenceId); + if (value.IsEmpty) + { + throw new AppCredentialException( + $"Credential '{referenceId}' resolved to an empty secret."); + } + + ReferenceId = referenceId; + _buffer = value.ToArray(); + } + + internal string ReferenceId { get; } + internal bool IsDisposed => _buffer is null; + + internal string Reveal() + { + ObjectDisposedException.ThrowIf(_buffer is null, this); + return new string(_buffer); + } + + public void Dispose() + { + char[]? buffer = Interlocked.Exchange(ref _buffer, null); + if (buffer is null) + return; + CryptographicOperations.ZeroMemory( + System.Runtime.InteropServices.MemoryMarshal.AsBytes( + buffer.AsSpan())); + } + + public override string ToString() => + $"[redacted:{ReferenceId}]"; +} + +internal sealed class AppCredentialException : Exception +{ + internal AppCredentialException(string message) + : base(message) + { + } + + internal AppCredentialException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/AcDream.App/GlobalUsings.cs b/src/AcDream.App/GlobalUsings.cs index 5213b6cc..83a9ab4d 100644 --- a/src/AcDream.App/GlobalUsings.cs +++ b/src/AcDream.App/GlobalUsings.cs @@ -1,4 +1,5 @@ global using AcDream.Runtime.Gameplay; global using AcDream.Runtime.Physics; +global using AcDream.Runtime.Chat; global using ILocalPlayerMotionSource = AcDream.Runtime.Gameplay.IRuntimeLocalPlayerMotionSource; diff --git a/src/AcDream.App/Net/ILiveInWorldSource.cs b/src/AcDream.App/Net/ILiveInWorldSource.cs index dd59d9f6..50f04757 100644 --- a/src/AcDream.App/Net/ILiveInWorldSource.cs +++ b/src/AcDream.App/Net/ILiveInWorldSource.cs @@ -12,5 +12,5 @@ internal interface ILiveWorldSessionSource internal interface ILiveUiSessionTarget : ILiveInWorldSource, ILiveWorldSessionSource { - AcDream.UI.Abstractions.ICommandBus Commands { get; } + AcDream.Runtime.Chat.ICommandBus Commands { get; } } diff --git a/src/AcDream.App/Net/LiveSessionCommandRouter.cs b/src/AcDream.App/Net/LiveSessionCommandRouter.cs index 0b0b44d4..2d781197 100644 --- a/src/AcDream.App/Net/LiveSessionCommandRouter.cs +++ b/src/AcDream.App/Net/LiveSessionCommandRouter.cs @@ -1,10 +1,8 @@ using AcDream.App.UI; using AcDream.Core.Chat; using AcDream.Core.Items; -using AcDream.Core.Net.Messages; using AcDream.Runtime.Gameplay; using AcDream.Runtime.Session; -using AcDream.UI.Abstractions; namespace AcDream.App.Net; @@ -149,6 +147,7 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting { private readonly object _gate = new(); private LiveCommandBus? _commands; + private LiveChatCommandRoute? _chatCommands; private ClientCommandController.Bindings? _clientCommands; private int _state; // 0 = constructed, 1 = active, 2 = disposed @@ -170,19 +169,22 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting var commands = new LiveCommandBus(); var clientCommands = new ClientCommandController( BuildGuardedClientCommands(bindings.ClientCommands)); - commands.Register(clientCommands.Execute); - commands.Register(command => - { - if (!string.IsNullOrEmpty(command.Text)) - SendIfActive(() => bindings.SendTalk(command.Text)); - }); - commands.Register(command => RouteChat(bindings, command)); + _chatCommands = new LiveChatCommandRoute(new LiveChatCommandBindings( + clientCommands.Execute, + bindings.Communication, + bindings.Chat, + bindings.TurbineChat, + bindings.CharacterState, + bindings.PlayerGuid, + bindings.SendTalk, + bindings.SendTell, + bindings.SendChannel, + bindings.SendTurbineChat, + bindings.Log)); // Campaign CH slice CH4 (2026-08-09): the 22 unregistered // ChannelSystem::GetChannelID fallback tags — bypasses // ChatChannelKind/ChannelResolver entirely and sends the raw // legacy ChatChannel (0x0147) broadcast directly. - commands.Register( - command => SendIfActive(() => bindings.SendChannel(command.ChannelId, command.Text))); commands.Register( command => SendIfActive(() => bindings.AddShortcut(command.Entry))); commands.Register( @@ -320,6 +322,7 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting { if (_state == 2) throw new ObjectDisposedException(nameof(LiveSessionCommandRouter)); + _chatCommands?.Activate(); _state = 1; } } @@ -329,202 +332,31 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting lock (_gate) { if (_state == 1) - _commands?.Publish(command); + { + if (_chatCommands?.TryPublish(command) != true) + _commands?.Publish(command); + } } } public void Dispose() { LiveCommandBus? commands; + LiveChatCommandRoute? chatCommands; lock (_gate) { _state = 2; commands = _commands; _commands = null; + chatCommands = _chatCommands; + _chatCommands = null; _clientCommands = null; } + chatCommands?.Dispose(); commands?.Clear(); } - /// - /// The seven values that ride Turbine - /// (0xF7DE), mapped to the lighter - /// reads. Every OTHER channel - /// kind (Fellowship/Vassals/Patron/Monarch/CoVassals/AllegianceBroadcast) - /// is legacy-only (0x0147) — those pipelines never overlap Turbine. - /// is the one exception, and - /// special-cases it BEFORE this table is - /// consulted: S3 (CH3 Opus review, 2026-08-09) corrected the original - /// CH3 filing (research doc §5.3) — retail's /a is bound to the - /// LEGACY AllegianceBroadcast bitflag by default and is only - /// rebound to DoTurbineChat_Allegiance once - /// StartupTurbineChatSystem successfully starts Turbine chat - /// (research doc §4.3). So "Turbine never started" (TurbineChat. - /// Enabled == false) still falls back to legacy, while "Turbine is - /// up but this character has no allegiance room" (Enabled == true, - /// AllegianceRoom == 0) correctly keeps retail's local - /// "Turbine chat is not available." refusal at the membership gate. - /// - private static readonly Dictionary TurbineChannelKinds = new() - { - [ChatChannelKind.Allegiance] = ChatChannelKindLite.Allegiance, - [ChatChannelKind.General] = ChatChannelKindLite.General, - [ChatChannelKind.Trade] = ChatChannelKindLite.Trade, - [ChatChannelKind.Lfg] = ChatChannelKindLite.Lfg, - [ChatChannelKind.Roleplay] = ChatChannelKindLite.Roleplay, - [ChatChannelKind.Society] = ChatChannelKindLite.Society, - [ChatChannelKind.Olthoi] = ChatChannelKindLite.Olthoi, - }; - - private void RouteChat( - LiveSessionCommandBindings bindings, - SendChatCmd command) - { - if (string.IsNullOrEmpty(command.Text)) - return; - - switch (command.Channel) - { - case ChatChannelKind.Say: - // ACE echoes HearSpeech to the sender. Retail therefore uses - // the authoritative inbound line rather than a local echo. - SendIfActive(() => bindings.SendTalk(command.Text)); - return; - - case ChatChannelKind.Tell: - if (string.IsNullOrEmpty(command.TargetName)) - return; - if (!SendIfActive(() => - bindings.SendTell(command.TargetName, command.Text))) - return; - bindings.Chat.OnSelfSent( - ChatKind.Tell, - command.Text, - // Retail's own "You tell ..." echo is Speech_Direct_Send - // (0x04), distinct from an incoming Tell's 0x03 — see - // ChatMessageType.OutgoingTell's "You tell ..." comment. - logTextType: (uint)RetailLogTextType.SpeechDirectSend, - targetOrChannel: command.TargetName); - return; - } - - // S3 (CH3 Opus review, 2026-08-09): see the TurbineChannelKinds doc - // comment above — Turbine chat never having started (no 0x0295 - // SetTurbineChatChannels received at all) still routes /a through - // the legacy AllegianceBroadcast bitflag, exactly like retail's - // default binding before StartupTurbineChatSystem runs. - if (command.Channel == ChatChannelKind.Allegiance - && !bindings.TurbineChat.Enabled) - { - RouteLegacyChannel(bindings, ChatChannelKind.AllegianceBroadcast, command.Text); - return; - } - - if (TurbineChannelKinds.TryGetValue(command.Channel, out ChatChannelKindLite liteKind)) - { - RouteTurbineChat(bindings, liteKind, command.Text); - return; - } - - RouteLegacyChannel(bindings, command.Channel, command.Text); - } - - /// - /// Step 2 of the CH3 fix list: retail - /// ClientCommunicationSystem::SendTurbineChat @0x0057db10's local - /// membership gate, raised through the same - /// RuntimeCommunicationState.AddText chokepoint CH2 built for - /// every other client-raised refusal. - /// - private void RouteTurbineChat( - LiveSessionCommandBindings bindings, - ChatChannelKindLite kind, - string text) - { - TurbineChatGateResult gate = TurbineChatMembershipGate.Evaluate( - kind, - bindings.TurbineChat, - bindings.CharacterState.Options, - bindings.CharacterState.IsOlthoiPlayer); - - // N3 (CH3 Opus review): the gate-result-to-refusal-text mapping is - // now shared with DirectGameRuntimeCommandAdapter.TrySendChannel via - // TurbineChatMembershipGate.ResolveRefusalText — this used to be an - // independent copy of the same switch. - if (gate.Status != TurbineChatGateStatus.Allowed) - { - if (TurbineChatMembershipGate.ResolveRefusalText(gate) is - (string refusalText, RetailLogTextType refusalType)) - { - bindings.Communication.AddText(refusalText, refusalType); - } - return; - } - - uint cookie = bindings.TurbineChat.NextContextId(); - uint senderGuid = bindings.PlayerGuid(); - bindings.Log?.Invoke( - $"chat: outbound TurbineChat {gate.DisplayName} " + - $"room=0x{gate.RoomId:X8} chatType={gate.ChatType} " + - $"cookie=0x{cookie:X} sender=0x{senderGuid:X8} len={text.Length}"); - SendIfActive(() => bindings.SendTurbineChat( - gate.RoomId, - gate.ChatType, - (uint)TurbineChat.DispatchType.SendToRoomById, - senderGuid, - text, - cookie)); - } - - private void RouteLegacyChannel( - LiveSessionCommandBindings bindings, - ChatChannelKind channel, - string text) - { - ChannelResolver.Resolved? legacy = ChannelResolver.Resolve(channel); - if (legacy is null) - { - bindings.Log?.Invoke( - $"chat: SendChatCmd kind={channel} dropped (no legacy id)"); - return; - } - - bindings.Log?.Invoke( - $"chat: outbound legacy ChatChannel {legacy.Value.DisplayName} " + - $"id=0x{legacy.Value.ChannelId:X8} len={text.Length}"); - if (!SendIfActive(() => - bindings.SendChannel(legacy.Value.ChannelId, text))) - return; - - // Step 5: wire ChatChannelInfo.IsSelfEchoChannel() — ACE resends - // Fellow/Vassals/Patron/Monarch/CoVassals to the sender with an - // empty sender name, so a local optimistic echo double-prints. S1 - // (CH3 Opus review, 2026-08-09) corrected AllegianceBroadcast into - // this SAME group: ACE's GameActionChatChannel handler iterates - // player.Allegiance.Members and the sender is one of them, so they - // get their own line back with their real name too — a different - // mechanism (no separate ""-sender resend) but the same - // double-print risk, so it must ALSO skip the local echo (research - // doc §3.7/§5.4, corrected). - bool serverEchoes = new ChatChannelInfo.Legacy( - legacy.Value.ChannelId, - legacy.Value.DisplayName).IsSelfEchoChannel(); - if (serverEchoes) - return; - - bindings.Chat.OnSelfSent( - ChatKind.Channel, - text, - targetOrChannel: legacy.Value.DisplayName, - // Precise per-bit own-send type (LegacyChannelChatType.Resolve's - // ownSend:true branch) — e.g. Fellowship keeps 0x13, Patron/ - // Vassal/Follower become 0x0B, the admin/audit/sentinel - // catch-all becomes 0x09 Channel_Send (corrected 2026-08-09, - // Opus review of 172c6f9a — was wrongly 0x0E). - logTextType: LegacyChannelChatType.Resolve(legacy.Value.ChannelId, ownSend: true)); - } - private ClientCommandController.Bindings BuildGuardedClientCommands( ClientCommandController.Bindings source) => new( TeleportToLifestone: () => InvokeClient(static b => b.TeleportToLifestone()), diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 174dfa3d..6af15653 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -28,6 +28,7 @@ using AcDream.Runtime; using AcDream.Runtime.Entities; using AcDream.Runtime.Gameplay; using AcDream.Runtime.Session; +using AcDream.Runtime.Chat; using AcDream.UI.Abstractions.Panels.Chat; using AcDream.UI.Abstractions.Panels.Vitals; using DatReaderWriter; @@ -104,6 +105,11 @@ internal sealed class LiveSessionRuntimeFactory private readonly LiveSessionCommandSurface _commands; private readonly Action _log; private readonly LiveMovementStatsApplier _movementStats; + private readonly SessionStatusWriter _statusWriter; + private readonly string _sessionId; + private readonly IReadOnlyList _loginCommands; + private readonly TimeSpan _loginCommandDelay; + private readonly TimeProvider _timeProvider; public LiveSessionRuntimeFactory( LiveSessionPlayerRuntime player, @@ -112,7 +118,12 @@ internal sealed class LiveSessionRuntimeFactory LiveSessionInteractionRuntime interaction, LiveSessionWorldRuntime world, LiveSessionCommandSurface commands, - Action log) + Action log, + SessionStatusWriter? statusWriter = null, + string sessionId = "app", + IReadOnlyList? loginCommands = null, + int loginCommandDelayMs = 500, + TimeProvider? timeProvider = null) { _player = player ?? throw new ArgumentNullException(nameof(player)); _domain = domain ?? throw new ArgumentNullException(nameof(domain)); @@ -122,6 +133,18 @@ internal sealed class LiveSessionRuntimeFactory _world = world ?? throw new ArgumentNullException(nameof(world)); _commands = commands ?? throw new ArgumentNullException(nameof(commands)); _log = log ?? throw new ArgumentNullException(nameof(log)); + // Campaign LA slice LA1: a no-op instance when the caller has no + // status file configured — every call site below stays unconditional. + _statusWriter = statusWriter ?? new SessionStatusWriter(null); + _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); + if (loginCommandDelayMs < 0) + { + throw new ArgumentOutOfRangeException( + nameof(loginCommandDelayMs)); + } + _loginCommands = loginCommands is null ? [] : [.. loginCommands]; + _loginCommandDelay = TimeSpan.FromMilliseconds(loginCommandDelayMs); + _timeProvider = timeProvider ?? TimeProvider.System; // C3c-F1: stat recomputes route through the Runtime movement owner's // typed application seam; App keeps zero direct controller mutations. _movementStats = new LiveMovementStatsApplier( @@ -142,6 +165,17 @@ internal sealed class LiveSessionRuntimeFactory LiveSessionResetPlan reset = LiveSessionResetManifest.Create( CreateResetBindings(resetHost)); + var loginCommands = new LoginCommandSequence( + _loginCommands, + _loginCommandDelay, + new RuntimeChatCommandFeedback(_domain.Communication), + _commands, + failure => _statusWriter.LoginCommandFailed( + _sessionId, + failure.CommandIndex, + failure.Command, + failure.Error), + _timeProvider); return new LiveSessionHost(controller, new LiveSessionHostBindings( Routing: new( CreateEventRouter, @@ -176,9 +210,31 @@ internal sealed class LiveSessionRuntimeFactory $"connecting to {host}:{port} as {user}", chatType: 1), Connected: () => + { _domain.Communication.Chat.OnSystemMessage( "connected — character list received", - chatType: 1)), + chatType: 1); + _statusWriter.Connected(_sessionId); + }, + Roster: roster => _statusWriter.CharacterList(_sessionId, roster), + CharacterEntered: selection => _statusWriter.EnteredWorld( + _sessionId, + selection.CharacterId, + selection.CharacterName), + LoginCommands: loginCommands, + // Campaign CC slice CC4: the two sibling events to Roster/ + // CharacterEntered above — see SessionStatusWriter's own doc + // for why characterCreated precedes an eventual enteredWorld + // rather than replacing it. + CharacterCreated: identity => _statusWriter.CharacterCreated( + _sessionId, + identity.Guid, + identity.Name), + CreationFailed: rejection => _statusWriter.CreationFailed( + _sessionId, + rejection.RawCode, + rejection.Reason, + rejection.AttemptedName)), connectOptions); } diff --git a/src/AcDream.App/Net/RetailSkillFormula.cs b/src/AcDream.App/Net/RetailSkillFormula.cs index 72688bae..f1238af6 100644 --- a/src/AcDream.App/Net/RetailSkillFormula.cs +++ b/src/AcDream.App/Net/RetailSkillFormula.cs @@ -1,3 +1,4 @@ +using AcDream.Core.CharGen; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; @@ -32,6 +33,58 @@ internal static class RetailSkillFormula result = (uint)Math.Floor((double)numerator / divisor + 0.5d); return true; } + + /// + /// Campaign CC CC5 review fix round, F3 (2026-08-16). Ports + /// CharGenState::GetSkillScore @ 0x005C4B50's FULL behavior, not + /// just the shared base: after the formula + /// result, retail adds a level-based bonus keyed off the skill's CURRENT + /// advancement class (edi_1 in the decomp) — edi_1 == 2 + /// (Trained) → result += 5; edi_1 == 3 (Specialized) → + /// result += 10 — before returning. The decomp's own gate, + /// if (edi_1 >= var_38) (var_38 resolves to + /// SkillBase.MinLevel — a decompiler-mangled local the raw + /// pseudo-C renders as an uninitialized read; DatReaderWriter's own + /// typed SkillBase.MinLevel field is the same value cleanly), is + /// satisfied for both callers of this method (Specialized=3 and + /// Trained=2 are the only two advancement classes CC5's Summary listbox + /// still shows — AP-224) FOR ANY MinLevel in {1, 2} — that + /// structural argument, not an assumption about the data, is what makes + /// omitting the branch safe. CC5 re-review residual round, R3 + /// (2026-08-16), corrects the data claim this comment used to make in + /// place of that argument ("no retail-authored skill sets MinLevel + /// above Untrained=1"): ACE's own SkillBase.cs annotates the + /// identical field // 1-2?, a hedge this port never checked. + /// MEASURED against the installed EoR dat's global SkillTable + /// (): + /// of the 38 priced skills, 23 carry MinLevel == 1 and 15 carry + /// MinLevel == 2 — ACE's hedge was correct (real 2s exist), and + /// no skill in the installed table exceeds 2, so the gate stays + /// satisfied — but the ORIGINAL claim ("no skill sets MinLevel above + /// Untrained=1") was false as written, unrelated to whether omitting + /// the branch happens to still be safe. A future caller passing + /// or + /// would need that + /// gate ported for real regardless of MinLevel's observed range. + /// + public static uint CalculateChargenScore( + SkillBase skillBase, + uint attribute1, + uint attribute2, + ChargenSkillAdvancementClass level) + { + ArgumentNullException.ThrowIfNull(skillBase); + + if (!TryCalculate(skillBase.Formula, attribute1, attribute2, out uint result)) + return 0u; + + return level switch + { + ChargenSkillAdvancementClass.Trained => result + 5u, + ChargenSkillAdvancementClass.Specialized => result + 10u, + _ => result, + }; + } } /// @@ -70,3 +123,54 @@ internal sealed class LiveSkillCreditResolver(SkillTable? skillTable) : 0u; } } + +/// +/// Campaign CC CC5 review fix round, F3 (2026-08-16). Chargen-side sibling +/// of : resolves +/// against the SAME +/// global SkillTable (portal.dat 0x0E000004), fed by a +/// candidate character's CHARGEN attribute spread (, +/// keyed the same way AcDream.Runtime.Session.ChargenAttributeId +/// already does — verified against DatReaderWriter's own +/// DatReaderWriter.Enums.AttributeId generated enum, which carries the +/// identical Strength=1/Endurance=2/Quickness=3/Coordination=4/Focus=5/ +/// Self=6 numbering) rather than a live player's server-echoed current +/// attributes. Wired at composition time +/// (InteractionRetainedUiComposition.cs) so +/// CharacterCreationSummaryPage never needs a DAT/Chorizite +/// dependency of its own — same shape as that composition's existing +/// ResolveText binding. +/// +internal sealed class ChargenSkillScoreResolver(SkillTable? skillTable) +{ + public uint Resolve( + uint skillId, + ChargenAttributeValues attributes, + ChargenSkillAdvancementClass level) + { + if (skillTable?.Skills is null + || !skillTable.Skills.TryGetValue( + (DatReaderWriter.Enums.SkillId)skillId, + out var skillBase)) + { + return 0u; + } + + uint attribute1 = ResolveAttribute(skillBase.Formula.Attribute1, attributes); + uint attribute2 = ResolveAttribute(skillBase.Formula.Attribute2, attributes); + return RetailSkillFormula.CalculateChargenScore(skillBase, attribute1, attribute2, level); + } + + private static uint ResolveAttribute( + DatReaderWriter.Enums.AttributeId attributeId, + ChargenAttributeValues attributes) => attributeId switch + { + DatReaderWriter.Enums.AttributeId.Strength => (uint)Math.Max(0, attributes.Strength), + DatReaderWriter.Enums.AttributeId.Endurance => (uint)Math.Max(0, attributes.Endurance), + DatReaderWriter.Enums.AttributeId.Quickness => (uint)Math.Max(0, attributes.Quickness), + DatReaderWriter.Enums.AttributeId.Coordination => (uint)Math.Max(0, attributes.Coordination), + DatReaderWriter.Enums.AttributeId.Focus => (uint)Math.Max(0, attributes.Focus), + DatReaderWriter.Enums.AttributeId.Self => (uint)Math.Max(0, attributes.Self), + _ => 0u, + }; +} diff --git a/src/AcDream.App/Platform/GraphicalHostPlatformServices.cs b/src/AcDream.App/Platform/GraphicalHostPlatformServices.cs index ca466c6d..bd95460d 100644 --- a/src/AcDream.App/Platform/GraphicalHostPlatformServices.cs +++ b/src/AcDream.App/Platform/GraphicalHostPlatformServices.cs @@ -1,6 +1,7 @@ using System.Runtime.InteropServices; +using System.Runtime.Versioning; using AcDream.App.Rendering; -using AcDream.Runtime.Platform; +using AcDream.Platform; namespace AcDream.App.Platform; @@ -10,6 +11,21 @@ internal enum GraphicalHostOperatingSystem Linux, } +/// +/// Campaign LA slice LA1: a [SupportedOSPlatformGuard]-annotated +/// runtime-OS check, for code OUTSIDE Platform/ that needs a +/// CA1416-recognized guard around a Linux-only API (e.g. +/// AppCredentialResolver's File.GetUnixFileMode call) without +/// re-detecting the OS itself — LinuxPlatformBoundaryTests +/// .OperatingSystemChecksRemainInsidePlatformOwners requires every such +/// check to live under this folder. +/// +internal static class RuntimePlatformGuard +{ + [SupportedOSPlatformGuard("linux")] + internal static bool IsLinuxRuntime => System.OperatingSystem.IsLinux(); +} + internal sealed record GraphicalNativeDependency( string Feature, string PublishedFileName); diff --git a/src/AcDream.App/Platform/GraphicalLegacyConfigurationMigrator.cs b/src/AcDream.App/Platform/GraphicalLegacyConfigurationMigrator.cs index 8a7e2fd9..b858cb50 100644 --- a/src/AcDream.App/Platform/GraphicalLegacyConfigurationMigrator.cs +++ b/src/AcDream.App/Platform/GraphicalLegacyConfigurationMigrator.cs @@ -1,4 +1,4 @@ -using AcDream.Runtime.Platform; +using AcDream.Platform; namespace AcDream.App.Platform; diff --git a/src/AcDream.App/Plugins/AppPluginHost.cs b/src/AcDream.App/Plugins/AppPluginHost.cs index bfabab86..dc81ec44 100644 --- a/src/AcDream.App/Plugins/AppPluginHost.cs +++ b/src/AcDream.App/Plugins/AppPluginHost.cs @@ -18,6 +18,7 @@ public sealed class AppPluginHost : IPluginHost Ui = ui; } + public bool HasUi => true; public IPluginLogger Log { get; } public IGameState State { get; } public IEvents Events { get; } diff --git a/src/AcDream.App/Plugins/BufferedUiRegistry.cs b/src/AcDream.App/Plugins/BufferedUiRegistry.cs index bcab04fb..dc3c565e 100644 --- a/src/AcDream.App/Plugins/BufferedUiRegistry.cs +++ b/src/AcDream.App/Plugins/BufferedUiRegistry.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using AcDream.App.UI; using AcDream.Plugin.Abstractions; namespace AcDream.App.Plugins; @@ -8,20 +9,119 @@ namespace AcDream.App.Plugins; /// Program.cs before the GL window opens) until GameWindow drains them into the /// UiHost tree after construction. /// -public sealed class BufferedUiRegistry : IUiRegistry +public sealed class BufferedUiRegistry : IScopedUiRegistry { - public readonly record struct Pending(string MarkupPath, object Binding); + public readonly record struct Pending(string MarkupPath, object Binding) + { + internal long RegistrationId { get; init; } + } - private readonly List _pending = new(); + private sealed class Registration(string markupPath, object binding) + { + internal string MarkupPath { get; } = markupPath; + internal object Binding { get; } = binding; + internal bool Drained { get; set; } + internal UiRoot? Root { get; set; } + internal UiElement? Element { get; set; } + } + + private readonly object _gate = new(); + private readonly Dictionary _registrations = []; + private long _nextRegistrationId; public void AddMarkupPanel(string markupPath, object binding) - => _pending.Add(new Pending(markupPath, binding)); + => _ = RegisterMarkupPanel(markupPath, binding); - /// Return + clear all buffered registrations. + public IDisposable RegisterMarkupPanel(string markupPath, object binding) + { + ArgumentException.ThrowIfNullOrWhiteSpace(markupPath); + ArgumentNullException.ThrowIfNull(binding); + long id; + lock (_gate) + { + id = checked(++_nextRegistrationId); + _registrations.Add(id, new Registration(markupPath, binding)); + } + return new RegistrationToken(this, id); + } + + /// Returns each not-yet-drained active registration once. public IReadOnlyList Drain() { - var copy = _pending.ToArray(); - _pending.Clear(); - return copy; + lock (_gate) + { + var pending = new List(_registrations.Count); + foreach ((long id, Registration registration) in _registrations) + { + if (registration.Drained) + continue; + registration.Drained = true; + pending.Add(new Pending( + registration.MarkupPath, + registration.Binding) + { + RegistrationId = id, + }); + } + return pending; + } + } + + internal void CompleteMount(Pending pending, UiRoot root, UiElement element) + { + bool stillRegistered; + lock (_gate) + { + stillRegistered = _registrations.TryGetValue( + pending.RegistrationId, + out Registration? registration); + if (stillRegistered) + { + registration!.Root = root; + registration.Element = element; + } + } + + // A plugin can fail/disable while markup is being built. Never leave + // the just-built child mounted if its host-owned token was rolled back. + if (!stillRegistered) + root.RemoveChild(element); + } + + internal void FailMount(Pending pending) => Remove(pending.RegistrationId); + + internal int RegistrationCount + { + get + { + lock (_gate) + return _registrations.Count; + } + } + + private void Remove(long id) + { + UiRoot? root; + UiElement? element; + lock (_gate) + { + if (!_registrations.Remove(id, out Registration? registration)) + return; + root = registration.Root; + element = registration.Element; + } + + if (root is not null && element is not null) + root.RemoveChild(element); + } + + private sealed class RegistrationToken( + BufferedUiRegistry owner, + long registrationId) : IDisposable + { + private BufferedUiRegistry? _owner = owner; + + public void Dispose() => + Interlocked.Exchange(ref _owner, null)?.Remove(registrationId); } } diff --git a/src/AcDream.App/Plugins/GraphicalPluginSession.cs b/src/AcDream.App/Plugins/GraphicalPluginSession.cs new file mode 100644 index 00000000..a968028c --- /dev/null +++ b/src/AcDream.App/Plugins/GraphicalPluginSession.cs @@ -0,0 +1,99 @@ +using AcDream.Core.Plugins; +using AcDream.Platform; +using AcDream.Plugin.Abstractions; +using AcDream.Runtime.Session; + +namespace AcDream.App.Plugins; + +/// +/// Graphical-host composition for one plugin set. The shared +/// owns discovery and collectible lifetimes; this +/// adapter supplies the graphical roots and translates outcomes into the +/// launcher status stream. +/// +internal sealed class GraphicalPluginSession : IDisposable +{ + private readonly PluginSession _plugins; + private readonly string[] _roots; + private readonly IReadOnlyList? _allowList; + private readonly string _sessionId; + private readonly SessionStatusWriter _statusWriter; + private bool _started; + + private GraphicalPluginSession( + PluginSession plugins, + string[] roots, + IReadOnlyList? allowList, + string sessionId, + SessionStatusWriter statusWriter) + { + _plugins = plugins; + _roots = roots; + _allowList = allowList; + _sessionId = sessionId; + _statusWriter = statusWriter; + } + + internal int LoadedCount => _plugins.LoadedCount; + + internal IReadOnlyList CaptureLoadContextWeakReferences() => + _plugins.CaptureLoadContextWeakReferences(); + + internal static GraphicalPluginSession Create( + ApplicationPathSet paths, + IReadOnlyList? allowList, + string sessionId, + IPluginHost host, + SessionStatusWriter statusWriter) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentNullException.ThrowIfNull(host); + ArgumentNullException.ThrowIfNull(statusWriter); + + var plugins = new PluginSession( + host, + status => Report(statusWriter, sessionId, status)); + return new GraphicalPluginSession( + plugins, + [ + Path.Combine(AppContext.BaseDirectory, "plugins"), + paths.PluginsDirectory, + ], + allowList, + sessionId, + statusWriter); + } + + internal void Start() + { + if (_started) + throw new InvalidOperationException( + "The graphical plugin session has already started."); + _started = true; + + // Both real hosts publish the same startup prefix: started first, + // then one outcome for each configured plugin, then connection work. + _statusWriter.Started(_sessionId); + _plugins.Start(_roots, _allowList); + } + + public void Dispose() => _plugins.Dispose(); + + private static void Report( + SessionStatusWriter writer, + string sessionId, + PluginSessionStatus status) + { + if (status.Kind == PluginSessionStatusKind.Loaded) + { + writer.PluginLoaded(sessionId, status.Plugin); + return; + } + + writer.PluginFailed( + sessionId, + status.Plugin, + status.Error ?? "plugin failed"); + } +} diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs index 7380feb1..8b0407bb 100644 --- a/src/AcDream.App/Program.cs +++ b/src/AcDream.App/Program.cs @@ -1,9 +1,10 @@ using AcDream.App; +using AcDream.App.Configuration; +using AcDream.App.Credentials; using AcDream.App.Plugins; using AcDream.App.Platform; using AcDream.App.Rendering; -using AcDream.Core.Plugins; -using AcDream.Runtime.Platform; +using AcDream.Platform; using Serilog; GraphicalHostPlatformServices graphicalPlatform = @@ -32,17 +33,110 @@ Log.Information( dependency => $"{dependency.Feature}={dependency.PublishedFileName}"))); -var datDir = args.FirstOrDefault() ?? Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); -if (string.IsNullOrWhiteSpace(datDir)) +// Campaign LA slice LA1: --session-config is purely additive — the +// existing one positional dat-dir argument and every ACDREAM_* env var keep +// working exactly as before when the flag is absent. See +// docs/plans/2026-08-14-launcher-campaign.md LA1. +// +// Review fix F5 (LA1 review round): a trailing, valueless --session-config +// (the flag typed as the LAST argument, nothing after it) must be a hard +// error, never a silent fall-through to the env-var path — a launcher that +// mis-composed its argv would otherwise appear to work while quietly +// ignoring the session-config contract entirely. +string? sessionConfigFlagPath = SessionConfigArgumentParsing.ExtractFlagValue( + args, "--session-config", out bool sessionConfigFlagPresent); +if (sessionConfigFlagPath is null && sessionConfigFlagPresent) { - Log.Error("usage: AcDream.App (or set ACDREAM_DAT_DIR)"); + Log.Error( + "--session-config requires a value (a path to the session-config document)."); return 2; } +string[] positionalArgs = + SessionConfigArgumentParsing.WithoutFlagAndValue(args, "--session-config"); + +var datDirArg = positionalArgs.FirstOrDefault(); +var envDatDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); // Single read of the startup-time process environment. Every downstream // consumer (GameWindow + collaborators) reads the typed bundle, not the // raw env vars. See docs/architecture/code-structure.md §2 Rule 4. -var runtimeOptions = RuntimeOptions.FromEnvironment(datDir); +RuntimeOptions runtimeOptions; +if (sessionConfigFlagPath is not null) +{ + SessionConfiguration sessionConfig; + SessionDescriptor session; + try + { + (sessionConfig, session) = SessionConfigurationLoader.Load(sessionConfigFlagPath); + } + catch (Exception error) + when (error is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException + or System.Text.Json.JsonException + or SessionConfigurationException) + { + Log.Error("--session-config invalid: {Error}", error.Message); + return 2; + } + + string? resolvedDatDir = + NullIfEmpty(sessionConfig.Process?.Content?.DatDirectory) + ?? NullIfEmpty(datDirArg) + ?? NullIfEmpty(envDatDir); + if (resolvedDatDir is null) + { + Log.Error( + "usage: AcDream.App (or set ACDREAM_DAT_DIR, " + + "or supply process.content.datDirectory in --session-config)"); + return 2; + } + + AppCredentialSecret? secret = null; + try + { + var resolver = new AppCredentialResolver( + Console.In, + applicationPaths.ConfigDirectory, + graphicalPlatform.OperatingSystem + == GraphicalHostOperatingSystem.Linux); + secret = resolver.Resolve(session.Id, session.Credential); + runtimeOptions = RuntimeOptions.FromSessionConfig( + resolvedDatDir, + Environment.GetEnvironmentVariable, + sessionConfigFlagPath, + sessionConfig, + session, + secret.Reveal()); + } + catch (AppCredentialException error) + { + Log.Error("--session-config credential unavailable: {Error}", error.Message); + return 2; + } + finally + { + secret?.Dispose(); + } + + // Env-var flow untouched when the flag is absent; when both are present + // the flag wins — this line makes that explicit rather than silent. + Log.Information( + "--session-config {Path} present; overriding ACDREAM_LIVE*/ACDREAM_TEST_* " + + "env-var live-session settings", + sessionConfigFlagPath); +} +else +{ + var datDir = datDirArg ?? envDatDir; + if (string.IsNullOrWhiteSpace(datDir)) + { + Log.Error("usage: AcDream.App (or set ACDREAM_DAT_DIR)"); + return 2; + } + runtimeOptions = RuntimeOptions.FromEnvironment(datDir); +} if (runtimeOptions.DevTools) { @@ -67,76 +161,16 @@ var host = new AppPluginHost( worldEvents, window.Selection, uiRegistry); - -var loaded = new List(); -var loadedPluginIds = new HashSet(StringComparer.OrdinalIgnoreCase); -StringComparer pathComparer = - graphicalPlatform.OperatingSystem - == GraphicalHostOperatingSystem.Windows - ? StringComparer.OrdinalIgnoreCase - : StringComparer.Ordinal; -string[] pluginRoots = -[ - .. new[] - { - Path.Combine(AppContext.BaseDirectory, "plugins"), - applicationPaths.PluginsDirectory, - }.Distinct(pathComparer), -]; - -foreach (string pluginsDir in pluginRoots) -{ - Log.Information("scanning plugins in {PluginsDir}", pluginsDir); - foreach (var result in PluginDiscovery.Scan(pluginsDir)) - { - if (!result.Success) - { - Log.Warning( - "plugin discovery failed for {Dir}: {Error}", - result.PluginDirectory, - result.Error); - continue; - } - - if (loadedPluginIds.Contains(result.Manifest!.Id)) - { - Log.Warning( - "skipping duplicate plugin id {Id} from {Dir}", - result.Manifest.Id, - result.PluginDirectory); - continue; - } - - var loadResult = PluginLoader.Load( - result.PluginDirectory, - result.Manifest, - host); - if (!loadResult.Success) - { - Log.Warning( - "plugin load failed for {Id}: {Error}", - result.Manifest.Id, - loadResult.Error); - continue; - } - - loadedPluginIds.Add(result.Manifest.Id); - loaded.Add(loadResult); - Log.Information( - "loaded plugin {Id} ({DisplayName})", - result.Manifest.Id, - result.Manifest.DisplayName); - } -} +GraphicalPluginSession pluginSession = GraphicalPluginSession.Create( + applicationPaths, + runtimeOptions.Plugins, + runtimeOptions.SessionId ?? "app", + host, + window.StatusWriter); +window.StartPluginHosting(pluginSession); try { - foreach (var plugin in loaded) - { - try { plugin.Plugin!.Enable(); } - catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); } - } - try { window.Run(); @@ -149,12 +183,14 @@ try } finally { - foreach (var plugin in loaded) - { - try { plugin.Plugin!.Disable(); } - catch (Exception ex) { Log.Error(ex, "plugin disable failed: {Id}", plugin.Manifest.Id); } - } Log.CloseAndFlush(); } return 0; + +// Campaign LA slice LA1: --session-config value-presence helper. The +// flag/positional-argument extraction itself lives in +// AcDream.App.Configuration.SessionConfigArgumentParsing (review fix F5) so +// its trailing-flag edge case is unit testable. +static string? NullIfEmpty(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value; diff --git a/src/AcDream.App/Rendering/ChargenPreviewAnimator.cs b/src/AcDream.App/Rendering/ChargenPreviewAnimator.cs new file mode 100644 index 00000000..b35b00d1 --- /dev/null +++ b/src/AcDream.App/Rendering/ChargenPreviewAnimator.cs @@ -0,0 +1,157 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.World; + +namespace AcDream.App.Rendering; + +/// +/// Owns the chargen preview's per-frame idle-loop ↔ rest-pose playback, +/// mirroring gmCG3DView::StartAnimation/StopAnimation's swap +/// (0x004EE600/0x004EE640) and +/// gmCGAppearancePage::ZoomIn/ZoomOut's immediate call into it +/// (0x0047D024/0x0047D160 — the swap happens the instant the +/// button is pressed, NOT once the camera's own 0.6s tween finishes). +/// +/// +/// Retail default is idle-PLAYING, not frozen — see +/// 's class doc for the decomp +/// citations. This class's own default ( starts +/// false) reproduces that: its constructor immediately plays the +/// idle animation's frame 0 when one resolved, matching +/// gmCGAppearancePage::Update's own trailing +/// if (m_bZoomedIn == 0) StartAnimation() gate +/// (~0x0047EF01-0x0047EF12), which re-fires on every heritage/gender/ +/// appearance change too — restarts the idle loop +/// at frame 0 on every transition INTO the playing state for the same +/// reason: set_sequence_animation's arg3=1 clears the sequence +/// before appending, so every StartAnimation call restarts the clip. +/// The DEFAULT-false claim itself rests on gmCGAppearancePage::InitializePage +/// @ 0x0047FDD0's explicit this->m_bZoomedIn = 0; at +/// 0x004802C3 — written immediately after that same function sets the +/// camera to the zoomed-IN per-heritage eye (0x00480286-0x0048029E), +/// not from the ctor simply never touching the field (heap operator new +/// memory is indeterminate, not zero — that argument doesn't hold on its +/// own). One retail quirk this implies: the character starts framed close-up +/// AND not-zoomed-in at the same time, so the FIRST Zoom In click (once +/// mounted) tweens close-eye→close-eye — visually null — while still +/// freezing the animation; the port reproduces this faithfully rather than +/// treating it as a bug. +/// +/// +/// +/// The page-mount half (CC6b, after CC4 merges) wires the Zoom In/Out +/// buttons to and the render loop to +/// ; nothing in this repository calls either yet. +/// +/// +internal sealed class ChargenPreviewAnimator +{ + /// + /// gmCG3DView::StartAnimation's literal framerate argument + /// (set_sequence_animation(this->m_pPlayerObject, + /// this->m_didAnimation.id, 1, 0, 30f), pseudo-C ~0x004ee61b). + /// + public const float IdleFramerate = 30f; + + private readonly ChargenPreviewAnimatedBuild _build; + private float _currFrame; + private bool _zoomedIn; + + // Double-buffered so a 30fps Tick doesn't allocate a fresh List + // every frame: one buffer is whatever Entity.MeshRefs currently points + // at (potentially still being read by the renderer's own Render() call + // for this frame), the other is safe to Clear()+refill for the NEXT + // tick and only gets published once fully populated. + private readonly List _meshRefsBufferA = []; + private readonly List _meshRefsBufferB = []; + private bool _nextBufferIsA = true; + + public ChargenPreviewAnimator(ChargenPreviewAnimatedBuild build) + { + _build = build ?? throw new ArgumentNullException(nameof(build)); + _currFrame = build.IdleLowFrame; + if (build.IdleAnimation is not null) + ApplyIdleFrame(); // retail's true default: idle playing, frame 0. + // Else: Entity.MeshRefs already holds RestMeshRefs (set by + // TryBuildAnimated) as the best available fallback. + } + + /// The live preview entity — mutated in place by + /// and ; the renderer never needs to re-call + /// SetPreview after the first assignment (WorldEntity.MeshRefs + /// is read fresh every draw — see its own doc comment). + public WorldEntity Entity => _build.Entity; + + public bool IsZoomedIn => _zoomedIn; + + /// + /// gmCGAppearancePage::ZoomIn/ZoomOut's + /// StopAnimation/StartAnimation call, applied immediately + /// (retail does not wait for the camera tween to finish before swapping + /// animation state — see this class's own doc comment). No-op if + /// already in the requested state, matching retail's own early-return + /// guards (ZoomIn's if (m_bZoomedIn != 0) return, + /// ZoomOut's mirror). + /// + public void SetZoomedIn(bool zoomedIn) + { + if (_zoomedIn == zoomedIn) + return; + _zoomedIn = zoomedIn; + if (zoomedIn) + { + _build.Entity.MeshRefs = _build.RestMeshRefs; + } + else + { + _currFrame = _build.IdleLowFrame; + if (_build.IdleAnimation is not null) + ApplyIdleFrame(); + } + } + + /// + /// Advances the idle loop by . No-op + /// while zoomed in (the rest pose is frozen — retail's framerate-0 + /// set_sequence_animation call never advances) or when no idle + /// Animation resolved (heritage/DID gap; the entity keeps whatever pose + /// the constructor seeded). + /// + public void Tick(float elapsedSeconds) + { + if (_zoomedIn || _build.IdleAnimation is null || elapsedSeconds <= 0f) + return; + + _currFrame = RetailAnimationCyclePlayback.Advance( + _currFrame, _build.IdleLowFrame, _build.IdleHighFrame, IdleFramerate, elapsedSeconds); + ApplyIdleFrame(); + } + + private void ApplyIdleFrame() + { + DatReaderWriter.DBObjs.Animation animation = _build.IdleAnimation!; + IReadOnlyList parts = _build.DrawableParts; + List meshRefs = _nextBufferIsA ? _meshRefsBufferA : _meshRefsBufferB; + _nextBufferIsA = !_nextBufferIsA; + meshRefs.Clear(); + foreach (ChargenPreviewDrawablePart part in parts) + { + bool resolved = RetailAnimationCyclePlayback.TryInterpolatePart( + animation, _currFrame, _build.IdleLowFrame, _build.IdleHighFrame, + part.SetupPartIndex, out Vector3 origin, out Quaternion orientation); + // Same defensive default as ApplyHeldPoseTransforms: a part + // index the bracketing frame doesn't cover (a Setup/Animation + // part-count mismatch, never expected in practice) keeps + // identity rather than a degenerate zero quaternion. + if (!resolved) + { + origin = Vector3.Zero; + orientation = Quaternion.Identity; + } + Matrix4x4 transform = RetailHeldPose.ComposePartTransform(part.DefaultScale, origin, orientation); + meshRefs.Add(new MeshRef(part.GfxObjId, transform) { SurfaceOverrides = part.SurfaceOverrides }); + } + _build.Entity.MeshRefs = meshRefs; + } +} diff --git a/src/AcDream.App/Rendering/ChargenPreviewCamera.cs b/src/AcDream.App/Rendering/ChargenPreviewCamera.cs new file mode 100644 index 00000000..5ddabf9f --- /dev/null +++ b/src/AcDream.App/Rendering/ChargenPreviewCamera.cs @@ -0,0 +1,199 @@ +using System; +using System.Numerics; +using AcDream.Core.CharGen; + +namespace AcDream.App.Rendering; + +/// +/// Heritage-parameterized camera for the chargen 3D preview +/// (gmCG3DView, Appearance page viewport 0x100003bb / Summary +/// 0x10000406). Retail-exact eye positions, ported from +/// gmCGAppearancePage::Update @ 0x0047E8F0 (pseudo-C ~139037-139114, +/// which sets m_vectTargPosition/m_vectCurPosition per +/// heritage and snaps them together with no tween — CC6a's static preview +/// renders that snapped default, the "zoomed-in" framing) and cross-checked +/// against the IDENTICAL literals in gmCGAppearancePage::ZoomIn @ +/// 0x0047CF00 (pseudo-C ~137618-137638). Direction is always +/// (0,0,0)CreatureMode::SetCameraDirection resets the view +/// frame to IDENTITY — the SAME zero-yaw/zero-pitch convention +/// already established for the paperdoll (look +/// straight down +Y, +Z up); every camera position below is used AS the +/// world-space eye directly, matching that camera's approach. +/// +/// +/// Rotation is NOT a camera property. Retail's continuous-rotation +/// button (gmCGAppearancePage::DoRotation @ 0x0047CA80) advances a +/// HEADING applied to the preview CHARACTER (CPhysicsObj::set_heading +/// inside gmCG3DView::Update, pseudo-C ~242088) — the camera's own +/// position/direction never change during a rotation. The heading itself +/// lives on (CC6b: the +/// DoRotation/Rotate port) and is applied to the entity via +/// ChargenPreviewEntityBuilder.TryBuild/TryBuildAnimated's +/// heading parameter, not here; this class stays a fixed-per-heritage +/// eye, exactly like retail's own camera. +/// (CC6b: the ZoomIn/ZoomOut/DoZoomAnimation port) DOES +/// mutate this class's — zoom is a camera concern, unlike +/// rotation. +/// +/// +public sealed class ChargenPreviewCamera : ICamera +{ + private static readonly Vector3 Up = Vector3.UnitZ; // AC up-axis = +Z, same as DollCamera/ChaseCamera. + + private Vector3 _eye; + + public ChargenPreviewCamera(uint heritageId = 0u) + { + _eye = ResolveDefaultEye(heritageId); + } + + /// + /// The camera's current world-space eye. Settable so CC6b can react to a + /// heritage change without reconstructing the camera. + /// + public Vector3 Eye + { + get => _eye; + set => _eye = value; + } + + /// Re-derives for the given heritage id (retail's mHeritageGroup). + public void SetHeritage(uint heritageId) => _eye = ResolveDefaultEye(heritageId); + + /// + /// Retail default (zoomed-in) camera eye per heritage. All four profiles + /// share X=0; only (Y, Z) — the AC world-space forward + /// offset and height — vary. FOUR distinct profiles across the 13 + /// heritages, not five: standard heritages (Aluvian, Gharu'ndim, Sho, + /// Viamontian, Shadowbound, Gearknight, Lugian, Empyrean, Penumbraen, + /// Undead — everything except Tumerok/Olthoi/OlthoiAcid) share the SAME + /// numeric offset as Gearknight's own dedicated branch in the decomp. + /// + public static Vector3 ResolveDefaultEye(uint heritageId) => heritageId switch + { + (uint)ChargenHeritageGroup.Olthoi => new Vector3(0f, -1.85000002f, 1.85000002f), + (uint)ChargenHeritageGroup.OlthoiAcid => new Vector3(0f, -3.04999995f, 2.75f), + (uint)ChargenHeritageGroup.Tumerok => new Vector3(0f, -0.850000024f, 1.64999998f), + _ => new Vector3(0f, -0.550000012f, 1.64999998f), + }; + + /// + /// Retail zoomed-OUT camera eye per heritage + /// (gmCGAppearancePage::ZoomOut @ 0x0047D050, pseudo-C + /// ~137671-137687). CC6a does not implement the zoom button (CC6b) — + /// recorded here as the verified target CC6b's tween will animate + /// toward. Olthoi/OlthoiAcid each keep their own dedicated profile; + /// every other heritage — INCLUDING Tumerok, whose zoomed-IN profile is + /// special-cased but whose zoomed-OUT is not — shares one value. + /// + public static Vector3 ResolveZoomedOutEye(uint heritageId) => heritageId switch + { + (uint)ChargenHeritageGroup.Olthoi => new Vector3(0f, -3.79999995f, 1.14999998f), + (uint)ChargenHeritageGroup.OlthoiAcid => new Vector3(0f, -5.69999981f, 1.64999998f), + _ => new Vector3(0f, -2.5f, 0.95f), + }; + + /// + /// Seconds per 360° revolution for the continuous-rotation button + /// (gmCGAppearancePage::m_dRotationPerSec, ctor pseudo-C + /// ~137523-137524 / ~226652-226653: raw double bits low32=0x00000000, + /// high32=0x40080000 → exactly 3.0 — the decompiler shows this cleanly, + /// no reconstruction needed). Retail's own per-tick formula + /// (gmCGAppearancePage::DoRotation @ 0x0047CA80, pseudo-C + /// ~0x0047CAC7): deltaDegrees = ((now - lastRotateTime) / + /// RotationSecondsPerRevolution) * 360 — CC6b's rotation controller + /// consumes this constant in exactly that shape, not as a + /// degrees-per-second rate. NOT applied here; see this class's own doc + /// comment on why rotation is not a camera concern. + /// + public const float RotationSecondsPerRevolution = 3.0f; + + /// + /// Zoom tween duration in seconds + /// (gmCGAppearancePage::DoZoomAnimation @ 0x0047C960's + /// reset-if-invalid default, cross-confirmed by ZoomIn/ZoomOut's + /// own -0.1 sentinel write, which deliberately invalidates + /// m_dAnimDuration so the very next DoZoomAnimation tick + /// resets it to this same value). The campaign plan flagged this + /// constant as decompiler-garbled (both sites split the raw double + /// across two 32-bit stores, and the decompiler mis-renders the LOW + /// dword's store as a bogus float literal instead of raw bits) — it is + /// NOT unrecoverable: reinterpreting each garbled float literal as its + /// own raw 32-bit pattern and pairing it with the store's (clean) high + /// dword reconstructs an exact IEEE-754 double both times. + /// DoZoomAnimation's own reset path: low32 from + /// 4.17232506e-08f reinterpreted = 0x33333333, high32 = + /// 0x3fe33333 (clean) → exactly 0.6. Cross-check via + /// ZoomIn/ZoomOut's sentinel: low32 from + /// -1.58818684e-23f reinterpreted = 0x9999999A, high32 = + /// 0xbfb99999 (clean) → exactly -0.1, the well-known + /// IEEE-754 bit pattern for -0.1 (0xBFB999999999999A) — confirming + /// the reconstruction technique itself, not just this one value. + /// + public const float ZoomTweenDurationSeconds = 0.6f; + + public float FovRadians { get; set; } = MathF.PI / 4f; // retail CreatureMode default, same as DollCamera. + public float Near { get; set; } = 0.1f; + public float Far { get; set; } = 50f; + public float Aspect { get; set; } = 1f; + + public Matrix4x4 View => + Matrix4x4.CreateLookAt(_eye, _eye + Vector3.UnitY, Up); + + public Matrix4x4 Projection => + Matrix4x4.CreatePerspectiveFieldOfView(FovRadians, Aspect <= 0f ? 1f : Aspect, Near, Far); +} + +/// +/// Internal private-viewport adapter, mirroring DollViewportCamera's +/// role for . +/// +internal sealed class ChargenPreviewViewportCamera : IPrivateEntityViewportCamera +{ + private readonly ChargenPreviewCamera _camera; + + public ChargenPreviewViewportCamera(uint heritageId = 0u) + { + _camera = new ChargenPreviewCamera(heritageId); + } + + /// + /// CC6b-MOUNT seam: wraps an EXTERNALLY-owned + /// instead of constructing a private one. + /// needs a settable to tween — the + /// other constructor's private _camera field is unreachable from + /// outside this class, so the page-mount composition (which owns the + /// zoom controller) must supply the SAME camera instance both this + /// adapter and the zoom controller mutate/read. + /// + public ChargenPreviewViewportCamera(ChargenPreviewCamera camera) + { + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + } + + public void SetHeritage(uint heritageId) => _camera.SetHeritage(heritageId); + + public Vector3 Eye => _camera.Eye; + public float FovRadians + { + get => _camera.FovRadians; + set => _camera.FovRadians = value; + } + public float Near + { + get => _camera.Near; + set => _camera.Near = value; + } + public float Far + { + get => _camera.Far; + set => _camera.Far = value; + } + public float Aspect + { + get => _camera.Aspect; + set => _camera.Aspect = value; + } + public Matrix4x4 View => _camera.View; + public Matrix4x4 Projection => _camera.Projection; +} diff --git a/src/AcDream.App/Rendering/ChargenPreviewController.cs b/src/AcDream.App/Rendering/ChargenPreviewController.cs new file mode 100644 index 00000000..0b56adb8 --- /dev/null +++ b/src/AcDream.App/Rendering/ChargenPreviewController.cs @@ -0,0 +1,416 @@ +using System.Diagnostics; +using System.Numerics; +using AcDream.App.UI; +using AcDream.Content; +using AcDream.Core.CharGen; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using AcDream.Core.World; +using DatReaderWriter; + +namespace AcDream.App.Rendering; + +/// +/// Campaign CC slice CC6b-MOUNT: the page-mount half's control surface over +/// the CC6a/CC6b-PRE preview foundation. +/// is constructed BEFORE the graphical presentation pipeline exists (early +/// retained-UI composition — see 's +/// own late-bound-Func doc comment), so its zoom/rotate buttons bind against +/// this interface's default no-op-until-assigned shape rather than a +/// concrete renderer reference. +/// constructs the real once the +/// graphics backend exists and assigns it onto the page — mirroring exactly +/// how the paperdoll's viewport.Renderer = paperdollLease.Resource +/// late-assignment already works for a DIFFERENT screen's viewport. +/// +internal interface IChargenPreviewControl +{ + /// + /// Recomposes and rebuilds the preview entity when the heritage/gender/ + /// appearance selection actually changed since the last call (a cheap + /// no-op otherwise). Returns false when the selection cannot be + /// resolved/built (heritage or gender not yet chosen, or a missing dat + /// resource) — the caller (the page) simply leaves the previous frame on + /// screen, matching PaperdollFramePresenter's own + /// "keep the successful doll, retry next visible frame" precedent. + /// + bool Rebuild( + ChargenOptions options, + uint heritageId, + int genderKey, + ChargenAppearanceSelection selection); + + void ZoomIn(); + void ZoomOut(); + void RotateClockwise(); + void RotateCounterClockwise(); +} + +/// Gates the preview's per-frame work on whether the Appearance +/// PAGE (not just the leaf viewport widget) is the currently visible page — +/// mirrors IPaperdollInventoryVisibility's outer-frame gate. +internal interface IChargenPreviewPageVisibility +{ + bool IsVisible { get; } +} + +/// CC6b-MOUNT: narrow seam mirroring IPaperdollFrameView so +/// can be exercised with a fake view +/// in tests. +internal interface IChargenPreviewFrameView +{ + bool TryGetVisibleSize(out int width, out int height); + + void SetTextureHandle(uint textureHandle); +} + +/// Thin adapter over RetailUiRuntime.IsChargenPreviewPageVisible +/// — narrowed to so this +/// Rendering-namespace class doesn't need a direct dependency on the +/// UI/Layout-namespace RetailUiRuntime type beyond the one property +/// read. +internal sealed class RetailChargenPreviewPageVisibility : IChargenPreviewPageVisibility +{ + private readonly AcDream.App.UI.RetailUiRuntime _runtime; + + public RetailChargenPreviewPageVisibility(AcDream.App.UI.RetailUiRuntime runtime) => + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + + public bool IsVisible => _runtime.IsChargenPreviewPageVisible; +} + +/// Campaign CC slice CC5: the Summary page's own visibility gate — +/// same shape as , reading +/// RetailUiRuntime.IsSummaryPreviewPageVisible instead. +internal sealed class RetailSummaryPreviewPageVisibility : IChargenPreviewPageVisibility +{ + private readonly AcDream.App.UI.RetailUiRuntime _runtime; + + public RetailSummaryPreviewPageVisibility(AcDream.App.UI.RetailUiRuntime runtime) => + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + + public bool IsVisible => _runtime.IsSummaryPreviewPageVisible; +} + +/// Retained-UI visibility + texture publication, mirroring +/// RetailPaperdollFrameView. +internal sealed class RetailChargenPreviewFrameView : IChargenPreviewFrameView +{ + private readonly UiViewport _viewport; + private readonly IChargenPreviewPageVisibility _page; + + public RetailChargenPreviewFrameView( + UiViewport viewport, + IChargenPreviewPageVisibility page) + { + _viewport = viewport ?? throw new ArgumentNullException(nameof(viewport)); + _page = page ?? throw new ArgumentNullException(nameof(page)); + } + + public bool TryGetVisibleSize(out int width, out int height) + { + width = 0; + height = 0; + if (!_viewport.Visible || !_page.IsVisible) + return false; + + width = (int)_viewport.Width; + height = (int)_viewport.Height; + return true; + } + + public void SetTextureHandle(uint textureHandle) => + _viewport.TextureSlot = UiTextureTableHandle.ToSlot(textureHandle); +} + +/// +/// The real, dat-touching implementation of +/// plus the per-frame owner — +/// constructed once in +/// (same composition scope RetailPaperdollPoseApplicator is built in, +/// which has the real content.Dats/content.AnimationLoader/ +/// d.DatLock) and assigned onto the already-mounted Appearance page. +/// +/// +/// Camera/zoom/rotation ownership (CC6b-MOUNT bridges a CC6a/CC6b-PRE gap): +/// only ever built its OWN private +/// with no injection seam, but +/// needs a SETTABLE camera to +/// tween. This class owns the ONE +/// instance and hands it to the renderer via the new +/// overload, +/// so both the renderer's draw and the zoom controller's tween read/write +/// the exact same eye position. +/// +/// +/// +/// Rebuild vs per-frame ownership split, decomp-cited (retail +/// gmCGAppearancePage::Update @ 0x0047E8F0): the camera SNAPS to +/// the heritage's default (zoomed-in) eye only on a HERITAGE or GENDER +/// change (the two confirmed direct call sites of the outer Update — +/// InitializePage and the two gender-button handlers, +/// ListenToElementMessage cases 0x9d/0x9e) — spin/color/ +/// shade changes call the narrower SetSelection/SetColor/ +/// SetShade instead, none of which touch m_vectCurPosition. +/// reproduces that split: it always recomposes the +/// ObjDesc/mesh (every appearance field feeds gmCG3DView::Update's +/// rebuild eventually), but only resets the camera when heritage or gender +/// actually changed. m_fCurHeading (this class's +/// ) and m_bZoomedIn +/// (read through ) both live +/// on the PAGE in retail and are NEVER reset by Update — so a fresh +/// (unavoidable: it owns the resolved +/// drawable-part list, which changes with the mesh) is immediately restored +/// to the PREVIOUS zoom state, and the current accumulated heading is passed +/// into the rebuild rather than resetting to the retail default. +/// +/// +internal sealed class ChargenPreviewController : + IChargenPreviewControl, + IPrivateEntityViewportFrame, + IDisposable +{ + private readonly IChargenPreviewRenderer _renderer; + private readonly IChargenPreviewFrameView _view; + private readonly ChargenPreviewCamera _camera; + private readonly ChargenPreviewRotationController _rotation; + private readonly IDatReaderWriter _dats; + private readonly IAnimationLoader _animations; + private readonly IChargenPalSetSource _palSets; + private readonly IChargenClothingTableSource _clothingTables; + private readonly object _datLock; + private readonly bool _useZoomedOutEye; + private readonly uint _renderId; + private readonly uint _backdropRenderId; + private readonly Stopwatch _clock = Stopwatch.StartNew(); + + private ChargenPreviewAnimator? _animator; + private ChargenPreviewZoomController? _zoom; + private double _lastElapsedSeconds; + private bool _hasComposed; + private uint _lastHeritageId; + private int _lastGenderKey = -1; + private ChargenAppearanceSelection _lastSelection; + private bool _disposed; + + /// The SAME instance passed to the + /// 's own camera constructor + /// parameter — see this class's own doc comment on why the renderer and + /// the zoom controller must share one mutable camera. + /// Review fix round F5 (2026-08-16): + /// (the default) reproduces the Appearance + /// page's own zoomed-IN default eye + /// (gmCGAppearancePage::InitializePage @ 0x0047FDD0, + /// ). + /// reproduces the Summary page's own eye + /// (gmCGSummaryPage::InitializePage @ 0x0047bbf0, byte-decoded + /// eye literal (0, -2.5, 0.95) at ~0x0047bd14-0x0047bd44 — + /// exactly 's + /// default-heritage value, NOT the zoomed-in one this controller used + /// before the fix). CC5 re-review residual round, nit 1 (2026-08-16): + /// InitializePage alone only justifies the ONE-TIME seed below — + /// the STRONGER citation for why re-derives this + /// same eye PER HERITAGE on every heritage/gender change (not just + /// once) is gmCGSummaryPage::Update @ 0x0047baa0, which re-sets + /// the camera on every update using the identical per-heritage mapping + /// already + /// implements (0xc Olthoi → (0, -3.8, 1.15), 0xd + /// OlthoiAcid → (0, -5.7, 1.65), else → (0, -2.5, 0.95)) — + /// confirming the per-heritage re-derive below is retail-correct, not + /// an acdream-only elaboration on a one-shot init value. Summary has no + /// zoom buttons at all (retail's own viewport there is fixed-framing), + /// so this is a permanent camera profile for the controller's whole + /// lifetime, not a toggle. + public ChargenPreviewController( + IChargenPreviewRenderer renderer, + ChargenPreviewCamera camera, + IChargenPreviewFrameView view, + IDatReaderWriter dats, + IAnimationLoader animations, + IChargenPalSetSource palSets, + IChargenClothingTableSource clothingTables, + object datLock, + bool useZoomedOutEye = false, + // F16 (Campaign CC gate round 1 closeout): the render-id pair this + // controller stamps on the entities it builds — MUST match the + // pair the sibling ChargenPreviewRenderer was constructed with (see + // that class's own renderId/backdropRenderId parameters), since + // both feed the SAME shared TextureCache owner-tracking key. + // Defaults to the Appearance page's pair; the composition root + // passes the Summary pair explicitly for its own instance — see + // ChargenPreviewEntityBuilder.SummaryPreviewRenderId's own doc for + // why sharing the default here would be a real collision, not + // merely untidy. + uint renderId = ChargenPreviewEntityBuilder.PreviewRenderId, + uint backdropRenderId = ChargenPreviewEntityBuilder.PreviewBackdropRenderId) + { + _renderer = renderer ?? throw new ArgumentNullException(nameof(renderer)); + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + _view = view ?? throw new ArgumentNullException(nameof(view)); + _dats = dats ?? throw new ArgumentNullException(nameof(dats)); + _animations = animations ?? throw new ArgumentNullException(nameof(animations)); + _palSets = palSets ?? throw new ArgumentNullException(nameof(palSets)); + _clothingTables = clothingTables ?? throw new ArgumentNullException(nameof(clothingTables)); + _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock)); + _useZoomedOutEye = useZoomedOutEye; + _renderId = renderId; + _backdropRenderId = backdropRenderId; + _rotation = new ChargenPreviewRotationController(); + // Seed the eye NOW, matching whatever the first Rebuild's own + // heritageOrGenderChanged branch below would otherwise defer until + // the first successful compose — avoids one frame of the wrong + // (Appearance-profile) eye if this controller ever renders before + // Rebuild's first call succeeds. + if (_useZoomedOutEye) + _camera.Eye = ChargenPreviewCamera.ResolveZoomedOutEye(0u); + } + + /// Test-observability seam only — production callers use + /// /. + internal bool IsZoomedIn => _zoom?.IsZoomedIn ?? false; + + /// Test-observability seam only. + internal Vector3 CameraEye => _camera.Eye; + + public bool Rebuild( + ChargenOptions options, + uint heritageId, + int genderKey, + ChargenAppearanceSelection selection) + { + if (_disposed) + return false; + + if (_hasComposed + && heritageId == _lastHeritageId + && genderKey == _lastGenderKey + && selection.Equals(_lastSelection)) + { + return true; + } + + // Fix round F7 (BLOCKER, CC6a's own F4 re-introduced at a new site): + // TryCompose reaches ChargenAppearanceCatalog.TryGetPalSet/ + // TryGetClothingTable (_palSets/_clothingTables), which do lazy raw + // DatCollection.Get() reads on first use — DatCollection is NOT + // thread-safe (feedback_phase_a1_hotfix_saga.md), and this UI-thread + // Rebuild call is the catalog's first production call site. Every + // sibling DAT read in this same method already guards with + // _datLock (see the TryBuildAnimated call just below) — this one + // must too. + bool composed; + ChargenAppearanceResult result; + lock (_datLock) + { + composed = ChargenAppearanceFactory.TryCompose( + options, heritageId, genderKey, selection, + _palSets, _clothingTables, out result); + } + if (!composed) + { + return false; + } + + Quaternion heading = MoveToMath.SetHeading( + Quaternion.Identity, _rotation.HeadingDegrees); + ChargenPreviewAnimatedBuild? build = ChargenPreviewEntityBuilder.TryBuildAnimated( + _dats, _animations, result, heritageId, heading, _datLock, _renderId); + if (build is null) + return false; + + bool wasZoomedIn = _animator?.IsZoomedIn ?? false; + _animator = new ChargenPreviewAnimator(build); + if (wasZoomedIn) + _animator.SetZoomedIn(true); + + bool heritageOrGenderChanged = + !_hasComposed || heritageId != _lastHeritageId || genderKey != _lastGenderKey; + if (heritageOrGenderChanged) + { + // F5: the Summary controller (_useZoomedOutEye) re-derives the + // FIXED zoomed-out eye per heritage instead of SetHeritage's + // zoomed-in default — see the ctor param's own doc comment. + _camera.Eye = _useZoomedOutEye + ? ChargenPreviewCamera.ResolveZoomedOutEye(heritageId) + : ChargenPreviewCamera.ResolveDefaultEye(heritageId); + } + + // Batch D (GF-7/GF-14): retail's own backdrop-rebuild gate + // (gmCG3DView::Update's `m_bgSetupID.id != eax_32` check) fires + // whenever the HERITAGE's own environmentSetupID differs from the + // one currently shown — and that value is a pure function of + // heritage (ACCharGenData::GetHG(mHeritageGroup).environmentSetupID), + // never gender. Narrower than heritageOrGenderChanged on purpose: a + // gender-only change (or an appearance-only change, which never + // reaches this branch at all) would otherwise pay a redundant Setup + // dat fetch + mesh-reference acquire/release for a backdrop that + // cannot have changed. + bool heritageChanged = !_hasComposed || heritageId != _lastHeritageId; + if (heritageChanged) + { + WorldEntity? backdrop = + options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage) + ? ChargenPreviewEntityBuilder.TryBuildBackdrop( + _dats, heritage!.EnvironmentSetupId, _datLock, _backdropRenderId) + : null; + _renderer.SetBackdrop(backdrop); + } + + // ChargenPreviewZoomController's animator dependency is required at + // construction (fix round F2) — a fresh animator means a fresh + // controller, but it reads IsZoomedIn straight through the animator + // we just restored above, so zoom state itself survives the swap. + _zoom = new ChargenPreviewZoomController(heritageId, _camera, _animator); + + _renderer.SetPreview(_animator.Entity); + _hasComposed = true; + _lastHeritageId = heritageId; + _lastGenderKey = genderKey; + _lastSelection = selection; + return true; + } + + public void ZoomIn() => _zoom?.ZoomIn(); + public void ZoomOut() => _zoom?.ZoomOut(); + public void RotateClockwise() => _rotation.Toggle(ChargenRotateDirection.Clockwise); + public void RotateCounterClockwise() => _rotation.Toggle(ChargenRotateDirection.CounterClockwise); + + public void Render() + { + if (_disposed || !_view.TryGetVisibleSize(out int width, out int height)) + return; + + double now = _clock.Elapsed.TotalSeconds; + float deltaSeconds = (float)Math.Max(0.0, now - _lastElapsedSeconds); + _lastElapsedSeconds = now; + + _animator?.Tick(deltaSeconds); + _rotation.Tick(now); + _zoom?.Tick(now); + if (_animator is not null) + _animator.Entity.Rotation = _rotation.ToOrientation(); + + _view.SetTextureHandle(_renderer.Render(width, height)); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + // Fix round F9: release the preview entity NOW rather than leaving + // the leased renderer holding it until the renderer's OWN disposal + // (a separate manifest entry, one step later) — this class built + // the entity via Rebuild, so it releases it on its own teardown + // instead of relying on a downstream owner to notice. Batch D: the + // backdrop entity is the SAME kind of controller-built resource, so + // it releases on the same teardown for the same reason. + _renderer.SetPreview(null); + _renderer.SetBackdrop(null); + _animator = null; + _zoom = null; + // The renderer itself is a leased composition resource disposed by + // the composition root (mirrors PaperdollViewportRenderer — this + // class does not own its lifetime, only its per-frame drive). + } +} diff --git a/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs b/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs new file mode 100644 index 00000000..b6cf6f73 --- /dev/null +++ b/src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs @@ -0,0 +1,572 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.Content; +using AcDream.Core.CharGen; +using AcDream.Core.Meshing; +using AcDream.Core.Physics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Rendering; + +/// +/// One resolved drawable part of the chargen preview body — a Setup part +/// index (needed to sample Animation.PartFrames[frame].Frames[index] +/// and Setup.DefaultScale[index]) paired with its resolved GfxObj id, +/// default scale (captured once at build time — scale never changes across +/// an idle cycle), and surface overrides. +/// walks this list every tick without touching the dat source again. +/// +internal readonly record struct ChargenPreviewDrawablePart( + int SetupPartIndex, + uint GfxObjId, + Vector3 DefaultScale, + IReadOnlyDictionary? SurfaceOverrides); + +/// +/// The richer sibling of 's +/// result: the built (seeded with retail's true +/// default pose — see ) plus everything +/// needed to drive it frame-by-frame without re-touching the dat source — +/// the resolved drawable parts, the precomputed frozen rest pose, and the +/// resolved idle Animation + its frame range. +/// +internal sealed class ChargenPreviewAnimatedBuild +{ + public required WorldEntity Entity { get; init; } + public required IReadOnlyList DrawableParts { get; init; } + + /// + /// The held final-frame rest pose, precomputed once (retail: + /// gmCG3DView::StopAnimation's framerate-0 + /// set_sequence_animation call never advances, so there is + /// nothing to recompute per tick while zoomed in). Falls back to each + /// part's raw Setup-default transform (no-op) when the rest DID doesn't + /// resolve, matching the pre-CC6b ApplyHeldPose no-op behavior. + /// + public required IReadOnlyList RestMeshRefs { get; init; } + + /// Retail's live idle DID (m_didAnimation), or null if unresolved. + public Animation? IdleAnimation { get; init; } + public int IdleLowFrame { get; init; } + public int IdleHighFrame { get; init; } +} + +/// +/// Builds the chargen preview from a +/// — the App-layer counterpart to +/// , except this one resolves its OWN +/// MeshRefs from a Setup + the composed ObjDesc rather than receiving +/// already-resolved refs from a live entity (there is no live entity yet; +/// character creation hasn't happened). DAT-touching, unlike +/// 's pure index-agnostic builder — the +/// closest existing precedent for the actual mesh-flatten/apply-changes/ +/// resolve-surface-overrides steps is +/// DatLiveEntityProjectionMaterializer.TryMaterialize, trimmed to +/// what a private, non-collision preview scene needs. +/// +/// +/// CC6b: retail's chargen preview does NOT default to a frozen pose — +/// gmCGAppearancePage::Update's own trailing gate +/// (~0x0047EF01-0x0047EF12) calls gmCG3DView::StartAnimation (idle +/// loop playing) whenever m_bZoomedIn == 0, and that default is +/// DIRECTLY ASSIGNED, not inherited: +/// gmCGAppearancePage::InitializePage @0x0047FDD0 writes an +/// explicit m_bZoomedIn = 0 at 0x004802C3 (right after +/// setting the camera to the zoomed-IN per-heritage eye at +/// 0x00480286-0x0048029E — the null-tween quirk the zoom +/// controller's doc records). The earlier elided-ctor-byte argument was +/// unsound (heap-new members are indeterminate, not zero) and was +/// replaced by this citation at the CC6b-PRE re-review. So retail's +/// chargen preview plays its idle loop (m_didAnimation, 30fps) from +/// the very first frame; the REST pose (m_didAnimationRest, held +/// final frame, this class's pre-CC6b-only behavior) only appears once the +/// user presses Zoom In (gmCGAppearancePage::ZoomIn calls +/// gmCG3DView::StopAnimation immediately, before its camera tween +/// even starts). keeps its ORIGINAL (rest-only) +/// behavior unchanged for its existing callers; +/// plus are the new, retail-accurate +/// entry point a live preview (idle-playing by default, freezing on zoom-in) +/// should use. +/// +/// +internal static class ChargenPreviewEntityBuilder +{ + /// Reserved synthetic guid for the chargen preview clone — + /// same reserved family as + /// (0xDA11D0xx) and CreatureAppraisalEntityBuilder (0xDA11D02x). + public const uint PreviewServerGuid = 0xDA11_D031u; + + /// Reserved render-local entity id — passed in + /// animatedEntityIds by the renderer so a re-dress (a new + /// selection) bypasses WbDrawDispatcher's Tier-1 classification + /// cache, mirroring 's own + /// doc comment. + public const uint PreviewRenderId = 0xDA11_D032u; + + /// Reserved synthetic guid for the chargen preview's ENVIRONMENT + /// backdrop (GF-7/GF-14 fix) — next slot in the same 0xDA11D03x chargen + /// family as . + public const uint PreviewBackdropServerGuid = 0xDA11_D033u; + + /// Reserved render-local entity id for the backdrop object, + /// passed in animatedEntityIds alongside + /// so a heritage switch's new environment Setup also bypasses the + /// classification cache — same reasoning as 's + /// own doc comment, applied to retail's SECOND creature_mode_objects + /// member (gmCG3DView::m_pbgObject). + public const uint PreviewBackdropRenderId = 0xDA11_D034u; + + /// + /// F16 (Campaign CC gate round 1 closeout, 2026-08-16): the Summary + /// page's OWN preview render-local id — DISTINCT from + /// . Both the Appearance and Summary pages + /// construct their own ChargenPreviewRenderer, but they share + /// ONE process-wide TextureCache (Wb.IEntityTextureLifetime) + /// via LivePresentationComposition's foundation.TextureCache + /// — confirmed by tracing FixedEntityTextureOwnerLease.Replace → + /// TextureCache.ReleaseOwnerCompositeTextureArrayCache.ReleaseOwner + /// → its own _owners tracker, keyed ONLY by the raw + /// ownerLocalId uint with no per-renderer namespace. Both pages + /// are mounted as PERMANENT siblings (register AP-229) and can be + /// simultaneously live, so two PrivateEntityViewportRenderer + /// instances sharing would share this + /// SAME owner bucket: either page re-dressing its own entity (a + /// FixedEntityTextureOwnerLease.Replace call) or being disposed + /// would call ReleaseOwner(PreviewRenderId) and release textures + /// the OTHER page's preview is still actively drawing with — a real + /// cross-page texture-corruption path, not a theoretical one. Reserved + /// in the SAME 0xDA11D0xx synthetic family, next free slot after the + /// Appearance page's own pair. + /// + public const uint SummaryPreviewRenderId = 0xDA11_D035u; + + /// F16: the Summary page's own backdrop render-local id, + /// paired with exactly as + /// pairs with + /// — see that constant's own doc for why a + /// distinct id is required, not merely tidy. + public const uint SummaryPreviewBackdropRenderId = 0xDA11_D036u; + + /// + /// Retail's held-pose (REST) animation DID enum key, resolved through + /// master map slot 7 exactly like RetailPaperdollPoseApplicator.ResolvePoseDid + /// — 0x10000005 for every standard heritage (the SAME enum id the + /// paperdoll's own held pose reads), matching + /// gmCG3DView's ctor / ::Update per-heritage + /// m_didAnimationRest assignment (pseudo-C ~0x004EE948, + /// ~0x004EEC43). Olthoi and OlthoiAcid each get their OWN distinct rest + /// DID — the one divergence from the paperdoll, which never needs an + /// Olthoi branch because a live player can't be one. + /// + private static uint ResolveRestPoseEnum(uint heritageId) => heritageId switch + { + (uint)ChargenHeritageGroup.Olthoi => 0x10000011u, + (uint)ChargenHeritageGroup.OlthoiAcid => 0x10000013u, + _ => 0x10000005u, + }; + + /// + /// Retail's LIVE idle-loop animation DID enum key (m_didAnimation, + /// the one gmCG3DView::StartAnimation plays at 30fps) — 0x10000006 + /// for every standard heritage, matching gmCG3DView's ctor / + /// ::Update per-heritage assignment (pseudo-C ~0x004ee6cc, + /// ~0x004eec2d). Olthoi and OlthoiAcid use the SAME did for BOTH idle + /// and rest (0x10000011 / 0x10000013 respectively, pseudo-C + /// ~0x004ee7e9/0x004ee7ff and ~0x004ee892/0x004ee8a8) — a genuine retail + /// quirk, not a porting shortcut: those two heritages show no visible + /// difference between "idle playing" and "zoomed in and frozen" in the + /// chargen preview. + /// + private static uint ResolveIdleAnimEnum(uint heritageId) => heritageId switch + { + (uint)ChargenHeritageGroup.Olthoi => 0x10000011u, + (uint)ChargenHeritageGroup.OlthoiAcid => 0x10000013u, + _ => 0x10000006u, + }; + + /// + /// Builds the STATIC (held rest-pose) preview entity, or null when the + /// resolved body Setup isn't in the dat source (a corrupted/incomplete + /// install — the same failure shape + /// treats as "drop this + /// spawn"). Unchanged since CC6a for its RESULT — a thin wrapper over + /// that returns exactly the same + /// WorldEntity (rest-posed) this method's existing callers already + /// expect; ALL 3 of those callers' tests still pass unmodified. Not + /// byte-identical internally any more — + /// also resolves the idle DID and loads the idle Animation before this + /// wrapper discards them, extra dat work the pre-CC6b method never did. + /// New code that wants retail's true default (idle loop playing) should call + /// and wrap the result in a + /// instead. + /// + /// + /// Shared exclusion object for every dat read this method performs. + /// DatCollection is NOT thread-safe (see + /// claude-memory/feedback_phase_a1_hotfix_saga.md) — every other + /// dat-touching renderer/resolver in this layer + /// (RetailPaperdollPoseApplicator, PlayerModeController, + /// DatProjectileSetupResolver, EquippedChildRenderController) + /// takes the SAME object datLock the composition root threads + /// through as RuntimeOptions/d.DatLock; callers MUST pass + /// that same shared instance, not a private lock, or this method's reads + /// race every other consumer's. + /// + public static WorldEntity? TryBuild( + IDatReaderWriter dats, + IAnimationLoader animations, + ChargenAppearanceResult appearance, + uint heritageId, + Quaternion heading, + object datLock, + uint renderId = PreviewRenderId) + { + ChargenPreviewAnimatedBuild? build = TryBuildAnimated( + dats, animations, appearance, heritageId, heading, datLock, renderId); + if (build is null) + return null; + + build.Entity.MeshRefs = build.RestMeshRefs; + return build.Entity; + } + + /// + /// Builds the preview entity PLUS everything a + /// needs to drive retail's idle-loop ↔ rest-pose swap without re-touching + /// the dat source. The returned + /// is initially posed with + /// (cheap, always available) — 's + /// constructor immediately reposes it to the true retail default (idle + /// frame 0) when an idle Animation resolved. + /// + public static ChargenPreviewAnimatedBuild? TryBuildAnimated( + IDatReaderWriter dats, + IAnimationLoader animations, + ChargenAppearanceResult appearance, + uint heritageId, + Quaternion heading, + object datLock, + // F16 (Campaign CC gate round 1 closeout): the Appearance and + // Summary pages both call this method through their own + // ChargenPreviewController, but must NOT stamp the same Id on + // both entities — see SummaryPreviewRenderId's own doc for the + // full TextureCache collision trace this id also feeds. + uint renderId = PreviewRenderId) + { + ArgumentNullException.ThrowIfNull(dats); + ArgumentNullException.ThrowIfNull(animations); + ArgumentNullException.ThrowIfNull(appearance); + ArgumentNullException.ThrowIfNull(datLock); + + uint setupId = appearance.SetupId; + List drawableParts; + List restMeshRefs; + Animation? idleAnimation; + int idleLowFrame = 0, idleHighFrame = -1; + + // Every dat read this method performs — the Setup fetch, both pose + // DID resolutions, the per-part GfxObj drawable checks, and the + // texture-change surface resolution — happens inside this one lock, + // mirroring RetailPaperdollPoseApplicator.Apply's "resolve + // everything under lock, then do pure processing" shape. + lock (datLock) + { + Setup? setup = dats.Get(setupId); + if (setup is null) + return null; + + var flattened = new List(SetupMesh.Flatten(setup)); + + foreach (ChargenAnimPartChange change in appearance.ObjDesc.AnimPartChanges) + { + if (change.PartIndex < flattened.Count) + flattened[change.PartIndex] = new MeshRef(change.PartId, flattened[change.PartIndex].PartTransform); + } + + // Rest pose: overwrite flattened's transforms with the held + // final frame (no-op — keeps Setup-default transforms — if the + // rest DID or its Animation don't resolve). + ApplyHeldPoseTransforms(dats, animations, setup, ResolveRestPoseEnum(heritageId), flattened); + + Dictionary>? surfaceOverrides = + ResolveSurfaceOverrides(dats, flattened, appearance.ObjDesc.TextureChanges); + + drawableParts = new List(flattened.Count); + restMeshRefs = new List(flattened.Count); + for (int partIndex = 0; partIndex < flattened.Count; partIndex++) + { + MeshRef part = flattened[partIndex]; + if (dats.Get(part.GfxObjId) is null) + continue; // matches DatLiveEntityProjectionMaterializer's drawable filter. + + IReadOnlyDictionary? overrides = null; + if (surfaceOverrides is not null && surfaceOverrides.TryGetValue(partIndex, out var perPart)) + overrides = perPart; + + restMeshRefs.Add(new MeshRef(part.GfxObjId, part.PartTransform) { SurfaceOverrides = overrides }); + + Vector3 defaultScale = partIndex < setup.DefaultScale.Count + ? setup.DefaultScale[partIndex] + : Vector3.One; + drawableParts.Add(new ChargenPreviewDrawablePart(partIndex, part.GfxObjId, defaultScale, overrides)); + } + if (drawableParts.Count == 0) + return null; + + // Idle DID: independent lookup, no mutation of flattened. + uint idleDid = RetailHeldPose.ResolvePoseDid(dats, ResolveIdleAnimEnum(heritageId)); + idleAnimation = (idleDid >> 24) == 0x03u ? animations.LoadAnimation(idleDid) : null; + if (idleAnimation is not null && idleAnimation.PartFrames.Count > 0) + { + idleLowFrame = 0; + idleHighFrame = idleAnimation.PartFrames.Count - 1; + } + else + { + idleAnimation = null; + } + } + + var entity = new WorldEntity + { + Id = renderId, + ServerGuid = PreviewServerGuid, + SourceGfxObjOrSetupId = setupId, + Position = Vector3.Zero, + Rotation = heading, + MeshRefs = restMeshRefs, + PaletteOverride = BuildPaletteOverride(appearance), + PartOverrides = BuildPartOverrides(appearance), + ParentCellId = null, + }; + + return new ChargenPreviewAnimatedBuild + { + Entity = entity, + DrawableParts = drawableParts, + RestMeshRefs = restMeshRefs, + IdleAnimation = idleAnimation, + IdleLowFrame = idleLowFrame, + IdleHighFrame = idleHighFrame, + }; + } + + /// + /// Builds the chargen preview's ENVIRONMENT BACKDROP entity — the fix for + /// GF-7/GF-14 (preview backdrop black on Appearance and Summary). + /// + /// + /// Decomp-cited: gmCG3DView::Update @0x004EE9D0 + /// (~0x004eecd3-0x004eed44) constructs a SECOND CPhysicsObj from + /// m_bgSetupID and adds it to the SAME viewport's + /// creature_mode_objects the player object lives in — BEFORE + /// the player is re-added (the player's own re-AddObject happens + /// much later, at ~0x004ef199, after the full clothing ObjDesc is + /// composed), so retail's own draw-list order is backdrop first, player + /// second. m_bgSetupID is compared against a freshly-read value the + /// decompiler elides (var_b8/eax_32, an unresolved-call + /// artifact — see claude-memory/feedback_bn_decomp_field_names.md) + /// immediately after ACCharGenData::GetHG(charGenData, mHeritageGroup) + /// (0x004eea1a) resolves the current heritage's HeritageGroup_CG; + /// acclient.h's verbatim struct layout + /// (HeritageGroup_CG.environmentSetupID, right after + /// setupID) confirms the elided value IS that field — i.e. THE + /// SAME id this codebase already parses as + /// + /// (ChargenTableReader.cs) but never consumed. The backdrop object + /// gets NO explicit position/orientation/scale anywhere in the function — + /// CPhysicsObj::makeObject(eax_32, 0, 1) (0x004eed2f) leaves it at + /// its physics-object default (origin, identity), exactly like the player + /// object's own placement in this same private scene. This method mirrors + /// that: a plain, unposed, unpalette-overridden Setup mesh at the origin. + /// + /// + /// + /// Both the Appearance page (gmCGAppearancePage) and the Summary + /// page (gmCGSummaryPage) call this SAME gmCG3DView::Update + /// function on their own gmCG3DView instance (confirmed at + /// pseudo-C ~0x0047bbf0/~0x0047c92c for Summary and ~0x0047c840/ + /// ~0x0047eee1 for Appearance) — so the backdrop mechanism is identical + /// for both viewports, not page-specific. + /// + /// + /// + /// . + /// Zero (unset/no environment authored for this heritage) returns null — + /// matches retail's own if (eax_32 != INVALID_DID.id) gate at + /// 0x004eed29, which skips makeObject/AddObject entirely + /// when the heritage has no environment Setup. + /// + public static WorldEntity? TryBuildBackdrop( + IDatReaderWriter dats, + uint environmentSetupId, + object datLock, + // F16 (Campaign CC gate round 1 closeout): see TryBuildAnimated's + // own renderId parameter doc — same Appearance-vs-Summary + // distinction, applied to the backdrop entity. + uint renderId = PreviewBackdropRenderId) + { + ArgumentNullException.ThrowIfNull(dats); + ArgumentNullException.ThrowIfNull(datLock); + + if (environmentSetupId == 0u) + return null; + + lock (datLock) + { + Setup? setup = dats.Get(environmentSetupId); + if (setup is null) + return null; + + var flattened = SetupMesh.Flatten(setup); + var drawable = new List(flattened.Count); + foreach (MeshRef part in flattened) + { + if (dats.Get(part.GfxObjId) is not null) + drawable.Add(part); + } + if (drawable.Count == 0) + return null; + + return new WorldEntity + { + Id = renderId, + ServerGuid = PreviewBackdropServerGuid, + SourceGfxObjOrSetupId = environmentSetupId, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = drawable, + ParentCellId = null, + }; + } + } + + /// No dat access — pure projection of the already-composed + /// ObjDesc's subpalettes, safe to call outside datLock. + private static PaletteOverride? BuildPaletteOverride(ChargenAppearanceResult appearance) + { + if (appearance.ObjDesc.SubPalettes.Count == 0) + return null; + + var ranges = new PaletteOverride.SubPaletteRange[appearance.ObjDesc.SubPalettes.Count]; + for (int i = 0; i < appearance.ObjDesc.SubPalettes.Count; i++) + { + ChargenSubPalette sub = appearance.ObjDesc.SubPalettes[i]; + ranges[i] = new PaletteOverride.SubPaletteRange(sub.SubPaletteId, sub.Offset, sub.NumColors); + } + return new PaletteOverride(appearance.BasePaletteId, ranges); + } + + /// No dat access — pure projection, safe to call outside + /// datLock. + private static PartOverride[] BuildPartOverrides(ChargenAppearanceResult appearance) + { + var partOverrides = new PartOverride[appearance.ObjDesc.AnimPartChanges.Count]; + for (int i = 0; i < appearance.ObjDesc.AnimPartChanges.Count; i++) + { + ChargenAnimPartChange change = appearance.ObjDesc.AnimPartChanges[i]; + partOverrides[i] = new PartOverride(change.PartIndex, change.PartId); + } + return partOverrides; + } + + /// + /// Overwrites every part's transform from the resolved pose DID's FINAL + /// frame — same "hold the settled last frame at zero frame rate" + /// approach as RetailPaperdollPoseApplicator.Apply + /// (RedressCreature @ 0x004A3C22), applied to the FULL + /// setup-part-indexed array (before drawable filtering) so the index + /// alignment holds even if a later part turns out to have a missing + /// GfxObj. No-ops (keeps the default placement frame) when the pose + /// DID or its animation can't be resolved. + /// + private static void ApplyHeldPoseTransforms( + IDatReaderWriter dats, + IAnimationLoader animations, + Setup setup, + uint poseEnum, + List flattened) + { + uint poseDid = RetailHeldPose.ResolvePoseDid(dats, poseEnum); + if ((poseDid >> 24) != 0x03u) + return; + + Animation? animation = animations.LoadAnimation(poseDid); + if (animation is null || animation.PartFrames.Count == 0) + return; + + var frame = animation.PartFrames[^1]; + for (int index = 0; index < flattened.Count; index++) + { + Vector3 scale = index < setup.DefaultScale.Count ? setup.DefaultScale[index] : Vector3.One; + Vector3 origin = Vector3.Zero; + Quaternion orientation = Quaternion.Identity; + if (index < frame.Frames.Count) + { + origin = frame.Frames[index].Origin; + orientation = frame.Frames[index].Orientation; + } + + flattened[index] = new MeshRef( + flattened[index].GfxObjId, + RetailHeldPose.ComposePartTransform(scale, origin, orientation)); + } + } + + /// + /// Part-index → (old texture id → new texture id) resolution, verbatim + /// port of DatLiveEntityProjectionMaterializer.ResolveSurfaceOverrides's + /// algorithm against instead of the + /// wire's CreateObject.TextureChange. + /// + private static Dictionary>? ResolveSurfaceOverrides( + IDatReaderWriter dats, + IReadOnlyList parts, + IReadOnlyList textureChanges) + { + if (textureChanges.Count == 0) + return null; + + var oldToNewByPart = new Dictionary>(); + foreach (ChargenTextureChange change in textureChanges) + { + if (!oldToNewByPart.TryGetValue(change.PartIndex, out var oldToNew)) + { + oldToNew = []; + oldToNewByPart.Add(change.PartIndex, oldToNew); + } + oldToNew[change.OldTextureId] = change.NewTextureId; + } + + var result = new Dictionary>(); + for (int partIndex = 0; partIndex < parts.Count; partIndex++) + { + if (!oldToNewByPart.TryGetValue(partIndex, out var oldToNew)) + continue; + + GfxObj? gfx = dats.Get(parts[partIndex].GfxObjId); + if (gfx is null) + continue; + + Dictionary? resolved = null; + foreach (var surfaceQid in gfx.Surfaces) + { + uint surfaceId = (uint)surfaceQid; + Surface? surface = dats.Get(surfaceId); + if (surface is null) + continue; + uint originalTexture = (uint)surface.OrigTextureId; + if (originalTexture == 0 || !oldToNew.TryGetValue(originalTexture, out uint newTexture)) + continue; + + (resolved ??= [])[surfaceId] = newTexture; + } + + if (resolved is not null) + result[partIndex] = resolved; + } + + return result.Count == 0 ? null : result; + } +} diff --git a/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs b/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs new file mode 100644 index 00000000..898710d8 --- /dev/null +++ b/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs @@ -0,0 +1,136 @@ +using AcDream.App.Rendering.Wb; +using AcDream.App.UI; +using AcDream.Core.Lighting; +using AcDream.Core.World; + +namespace AcDream.App.Rendering; + +/// +/// CC6b-MOUNT: narrow seam mirroring IPaperdollDollRenderer so +/// 's rebuild/render logic can be +/// exercised with a fake in tests without a live GPU device. +/// +internal interface IChargenPreviewRenderer +{ + void SetPreview(WorldEntity? entity); + + /// + /// Sets or clears the environment backdrop entity drawn BEHIND the + /// preview (Campaign CC gate round 1 Batch D, GF-7/GF-14) — retail's + /// gmCG3DView::m_pbgObject. See + /// for the + /// decomp-cited placement. + /// + void SetBackdrop(WorldEntity? entity); + + uint Render(int width, int height); +} + +/// +/// Chargen-specific facade over the shared private creature viewport +/// () — CC6a's foundation half of +/// the campaign plan's "chargen preview renderer" deliverable. Mirrors +/// 's shape exactly, with a +/// heading-capable in place of the +/// paperdoll's fixed one. +/// +/// +/// NOT wired here (CC6b page-mount half, after CC4 merges per the +/// campaign's parallelism contract): mounting into the authored +/// Appearance/Summary viewport ids (0x100003bb / 0x10000406) +/// and binding the spin/color-wheel/rotate/zoom widgets to +/// // +/// . This class is a standalone, +/// composition-root-agnostic renderer — nothing in +/// AcDream.App/UI/Layout/ or RetailUiRuntime.cs references it +/// yet. +/// +/// +/// +/// CC6b (pre-mount half): the preview now HAS a real live idle loop +/// (, retail's m_didAnimation DID +/// at 30fps via set_sequence_animation) instead of the CC6a-only held +/// rest pose — TS-83 is retired. still accepts a +/// static WorldEntity for callers that only want +/// ChargenPreviewEntityBuilder.TryBuild's unchanged rest-pose +/// snapshot; a caller that wants the animated preview constructs a +/// from +/// ChargenPreviewEntityBuilder.TryBuildAnimated and passes its +/// Entity here once — the animator mutates that SAME entity's +/// MeshRefs in place every Tick, and Render reads it +/// fresh (no re-SetPreview needed per frame; see +/// WorldEntity.MeshRefs's own "mutable so the animation tick can +/// replace it each frame" doc comment). +/// +/// +internal sealed class ChargenPreviewRenderer : + IUiViewportRenderer, + IChargenPreviewRenderer, + IDisposable +{ + private readonly PrivateEntityViewportRenderer _renderer; + private readonly ChargenPreviewViewportCamera _camera; + + internal ChargenPreviewRenderer( + IWorldPassScope scope, + AcDream.App.Rendering.Gpu.IGpuDevice device, + ICurrentGpuFrameSource frames, + WbDrawDispatcher dispatcher, + SceneLightingUboBinding lightUbo, + IEntityTextureLifetime textureLifetime, + IWbMeshAdapter meshAdapter, + uint heritageId = 0u, + ChargenPreviewCamera? camera = null, + // F16 (Campaign CC gate round 1 closeout): the Appearance and + // Summary pages each construct their OWN ChargenPreviewRenderer but + // share ONE process-wide TextureCache — see + // ChargenPreviewEntityBuilder.SummaryPreviewRenderId's own doc for + // the full collision trace. Defaulting to the Appearance page's + // pair keeps every pre-existing call site byte-identical; the + // composition root passes the Summary pair explicitly for its own + // instance. + uint renderId = ChargenPreviewEntityBuilder.PreviewRenderId, + uint backdropRenderId = ChargenPreviewEntityBuilder.PreviewBackdropRenderId) + { + // CC6b-MOUNT: when a caller supplies its own camera instance (the + // page-mount composition, which needs a SETTABLE camera for + // ChargenPreviewZoomController to tween — see + // ChargenPreviewController's own doc comment), wrap that exact + // instance instead of building a private, unreachable one. + _camera = camera is not null + ? new ChargenPreviewViewportCamera(camera) + : new ChargenPreviewViewportCamera(heritageId); + _renderer = new PrivateEntityViewportRenderer( + scope, + device, + frames, + dispatcher, + lightUbo, + textureLifetime, + meshAdapter, + renderId, + _camera, + "chargen preview", + // Batch D (GF-7/GF-14): reserves the second draw-entity slot for + // the heritage's environment Setup — see PrivateEntityViewportRenderer's + // own doc comment on backdropRenderId. + backdropRenderId); + } + + public bool TextureIsBottomUp => _renderer.TextureIsBottomUp; + + /// + /// Re-derives the fixed per-heritage camera eye + /// () — call whenever + /// the selected heritage changes, BEFORE the next . + /// + public void SetHeritage(uint heritageId) => _camera.SetHeritage(heritageId); + + public void SetPreview(WorldEntity? entity) => _renderer.SetEntity(entity); + + public void SetBackdrop(WorldEntity? entity) => _renderer.SetBackdrop(entity); + + public uint Render(int width, int height) => _renderer.Render(width, height); + + public void Dispose() => _renderer.Dispose(); +} diff --git a/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs b/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs new file mode 100644 index 00000000..3e7fa9e9 --- /dev/null +++ b/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs @@ -0,0 +1,176 @@ +using System.Numerics; +using AcDream.Core.Physics.Motion; + +namespace AcDream.App.Rendering; + +/// +/// Retail's toggle direction enum +/// (gmBarberUI::ERotateDirection/gmCGAppearancePage::ERotateDirection +/// typedef alias, acclient.h:6848-6852,6960): Invalid=0, +/// Clockwise=1, CounterClockwise=2. +/// +internal enum ChargenRotateDirection +{ + Invalid = 0, + Clockwise = 1, + CounterClockwise = 2, +} + +/// +/// Presentation-free port of gmCGAppearancePage::Rotate +/// (0x0047CB50) + DoRotation (0x0047CA80) — the +/// button-toggled continuous rotation retail applies to the preview +/// CHARACTER's heading (CPhysicsObj::set_heading inside +/// gmCG3DView::Update, pseudo-C ~0x0047eecf1), not the camera (see +/// 's own doc comment on why rotation +/// lives here instead). Retail drives once per frame from +/// a global-message-3 tick while is set +/// (gmCGAppearancePage::ListenToGlobalMessage @ 0x0047CED0); the +/// CC6b page-mount half will bind the Rotate Clockwise/Counter-Clockwise +/// buttons to and the render loop to . +/// +internal sealed class ChargenPreviewRotationController +{ + /// + /// Rotate's explicit sentinel write + /// (this->m_dLastRotateTime = -1.0, pseudo-C ~0x0047cba7/0x0047cbb1 + /// — the high dword 0xbff00000 paired with a zero low dword is the + /// exact IEEE-754 bit pattern for -1.0) — invalidates the + /// timestamp so the very next resets it to "now" + /// (a zero-length first delta) instead of computing a huge jump from a + /// stale or never-set value. + /// + private const double InvalidTimeSentinel = -1.0; + + private double _lastRotateTime = InvalidTimeSentinel; + private ChargenRotateDirection _direction = ChargenRotateDirection.Invalid; + private bool _rotating; + + /// + /// CC6b-MOUNT: retail's true OPERATIVE starting heading — not the ctor's + /// value. gmCGAppearancePage::gmCGAppearancePage @0x0047CCC0 sets + /// m_fCurHeading = 0f at 0x0047CDAC, but + /// gmCGAppearancePage::InitializePage @0x0047FDD0 — which always + /// runs immediately afterward, before the page is ever visible — writes + /// m_fCurHeading = 180f at 0x00480235 and pushes it into the + /// view via gmCG3DView::SetPlayerHeading(m_p3DView, 180f) at + /// 0x0048023F. No player-visible frame of chargen's Appearance + /// preview is EVER rendered at the ctor's 0° — 180° is the only heading a + /// user actually sees. The same override, independently, is what every + /// other gmCG3DView owner does for ITS own instance: + /// gmCGSummaryPage::InitializePage @0x0047BD54 (a separate + /// viewport/page, CC5's scope, not this one) and gmBarberUI + /// corroborate 180 TWICE, in two separate functions (fix round F4 + /// correction — the original citation here wrongly attributed both + /// writes to PostInit): gmBarberUI::PostInit @0x004de2e0 + /// has its OWN m_fCurHeading = 180f write at 0x004de330 + /// (no push there — PostInit ends right after that assignment); + /// separately, gmBarberUI::InitializePage @0x004e0040 has its OWN + /// redundant m_fCurHeading = 180f write at 0x004e03ab, + /// THEN pushes it via SetPlayerHeading(m_p3DView, 180f) at + /// 0x004e03b5 — the address the original citation attributed to + /// PostInit. Two functions, both landing on 180, not one + /// function pushing from the other's write. Since + /// this controller — like retail's m_fCurHeading — is itself the + /// PAGE-level heading owner (not the view's), matching the value every + /// real page converges on before its first frame is the retail-faithful + /// choice; requiring every future mount site to remember a separate + /// "seed to 180" call would be a trap (a forgotten seed silently faces + /// the character away from the camera). + /// + public const float RetailDefaultHeadingDegrees = 180f; + + public bool IsRotating => _rotating; + public ChargenRotateDirection Direction => _direction; + + /// Defaults to + /// (see that constant's doc for + /// the full ctor-vs-InitializePage citation) — the value every real + /// mount site should get for free. Tests that exercise the pure + /// rotation/wrap arithmetic pass 0f explicitly for simpler + /// relative-delta assertions; that is a test convenience, not a second + /// retail-cited default. + public ChargenPreviewRotationController( + float initialHeadingDegrees = RetailDefaultHeadingDegrees) + { + HeadingDegrees = initialHeadingDegrees; + } + + /// Retail's m_fCurHeading, degrees — applied to the + /// preview entity via MoveToMath.SetHeading + /// (CPhysicsObj::set_heading's exact port). See + /// for why this controller's + /// parameterless-constructor default is 180, not the ctor's raw 0. + /// + public float HeadingDegrees { get; private set; } + + /// + /// gmCGAppearancePage::Rotate @ 0x0047CB50: pressing the SAME + /// direction a second time while already rotating STOPS rotation + /// (retail's button-toggle UX); any other press (opposite direction, or + /// starting from stopped) sets that direction and (re)starts, + /// invalidating m_dLastRotateTime per this class's own sentinel + /// doc. + /// + public void Toggle(ChargenRotateDirection direction) + { + if (_rotating && direction == _direction) + { + _rotating = false; + return; + } + _direction = direction; + _lastRotateTime = InvalidTimeSentinel; + _rotating = true; + } + + /// + /// gmCGAppearancePage::DoRotation @ 0x0047CA80: per-tick + /// deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) + /// * 360, added for + /// and subtracted for every other direction (pseudo-C ~0x0047cacd: + /// if (m_eRotateDir != ECG_ROTATE_CLOCKWISE) heading -= delta; else + /// heading += delta;), then a SINGLE-PASS clamp back into + /// [0, 360) — not a full modulo loop; retail's own tail only + /// adds/subtracts 360 once (pseudo-C ~0x0047caf3-0x0047cb31), which is + /// exactly enough for any realistic per-frame delta and is reproduced + /// here verbatim rather than "improved" into a `%=`. Fix round F3: Binary + /// Ninja literally renders x87_r7_1 = x87_r6_3 at 0x0047CAEB + /// inside the counter-clockwise branch — reassigning the local that held + /// the "now" timestamp to the just-computed delta-degrees value — which + /// would make the 0x0047CB3D store into m_dLastRotateTime + /// write delta-degrees instead of the timestamp for CCW only; that is an + /// x87-FPU-stack modeling artifact of the decompiler, not real retail + /// behavior (a shipped feature where every counter-clockwise rotation + /// visibly diverges from clockwise is implausible, and + /// claude-memory/feedback_bn_decomp_field_names.md names exactly + /// this x87-stack-register mislabeling as a known decompiler artifact + /// class), so this port stores now into _lastRotateTime + /// unconditionally in BOTH directions. + /// + public void Tick(double now) + { + if (!_rotating) + return; + if (_lastRotateTime <= 0d) + _lastRotateTime = now; + + double deltaDegrees = ((now - _lastRotateTime) / ChargenPreviewCamera.RotationSecondsPerRevolution) * 360.0; + HeadingDegrees = _direction == ChargenRotateDirection.Clockwise + ? HeadingDegrees + (float)deltaDegrees + : HeadingDegrees - (float)deltaDegrees; + + if (HeadingDegrees < 0f) + HeadingDegrees += 360f; + if (HeadingDegrees > 360f) + HeadingDegrees -= 360f; + + _lastRotateTime = now; + } + + /// CPhysicsObj::set_heading's exact quaternion + /// construction — the SAME shared Core primitive retail movement already + /// ports (). + public Quaternion ToOrientation() => + MoveToMath.SetHeading(Quaternion.Identity, HeadingDegrees); +} diff --git a/src/AcDream.App/Rendering/ChargenPreviewZoomController.cs b/src/AcDream.App/Rendering/ChargenPreviewZoomController.cs new file mode 100644 index 00000000..1e018063 --- /dev/null +++ b/src/AcDream.App/Rendering/ChargenPreviewZoomController.cs @@ -0,0 +1,162 @@ +using System.Numerics; + +namespace AcDream.App.Rendering; + +/// +/// Presentation-free port of gmCGAppearancePage::ZoomIn/ZoomOut +/// (0x0047CF00/0x0047D050) and DoZoomAnimation +/// (0x0047C960): a linear 0.6s tween of the preview camera's eye +/// between (zoomed IN) +/// and (zoomed OUT), +/// driving the SAME zoom-state swap the +/// button presses trigger in retail — immediately, not once the tween +/// finishes (see 's own doc comment). +/// +/// +/// One owner of the zoom state (fix round F2): retail's +/// m_bZoomedIn is a SINGLE field on gmCGAppearancePage that +/// gates both the camera target AND the animation swap — there is no way +/// for retail's own camera and animation to disagree about which zoom state +/// they're in. The first cut of this port kept two independent bools (one +/// here, one on ) synced only by +/// / calling a NULLABLE animator +/// parameter — a null pass, or any direct +/// call bypassing this +/// controller, would desync the camera's target from the animation's pose. +/// This class now takes its as a +/// REQUIRED constructor dependency and reads +/// straight through to — the +/// animator is the sole state owner, matching retail's own single-field +/// design, and there is no longer a second bool that could disagree with it. +/// +/// +/// +/// Retail drives once per frame from a global-message-3 +/// tick while m_bShouldZoomAnimate is set +/// (gmCGAppearancePage::ListenToGlobalMessage @ 0x0047CED0); the +/// CC6b page-mount half will bind the Zoom In/Out buttons to +/// / and the render loop to +/// . Direction is always (0,0,0) for this camera +/// (see 's own remarks), so only the eye +/// position tweens — retail's own m_vectCurDirection lerp is a no-op +/// here and is not reproduced. +/// +/// +internal sealed class ChargenPreviewZoomController +{ + /// + /// ZoomIn/ZoomOut's explicit invalidation write + /// (this->m_dAnimDuration = -0.1, pseudo-C ~0x0047cff1/0x0047cffb + /// and ~0x0047d12c/0x0047d136 — the exact IEEE-754 bit pattern for + /// -0.1) so the very next resets the duration + /// to and the + /// start time to "now", matching DoZoomAnimation's own + /// reset-if-invalid guard exactly. + /// + private const double InvalidDurationSentinel = -0.1; + + private readonly uint _heritageId; + private readonly ChargenPreviewAnimator _animator; + private Vector3 _startEye; + private Vector3 _targetEye; + private double _animStartTime; + private double _animDuration; + private bool _shouldAnimate; + + public ChargenPreviewZoomController(uint heritageId, ChargenPreviewCamera camera, ChargenPreviewAnimator animator) + { + ArgumentNullException.ThrowIfNull(camera); + ArgumentNullException.ThrowIfNull(animator); + _heritageId = heritageId; + Camera = camera; + _animator = animator; + } + + public ChargenPreviewCamera Camera { get; } + + /// + /// Mirrors retail's m_bZoomedIn — a straight read-through to + /// (see this class's own + /// "one owner" doc above), which itself defaults false per + /// gmCGAppearancePage::InitializePage @ 0x0047FDD0's explicit + /// this->m_bZoomedIn = 0; at 0x004802C3 — written right + /// after that same function points the camera at the zoomed-IN + /// per-heritage eye (0x00480286-0x0048029E). One retail quirk + /// this produces: the character starts framed close-up while + /// NOT-zoomed-in, so the first Zoom In click (once mounted) tweens + /// close-eye→close-eye — visually null — while still freezing the + /// animation; this port reproduces it faithfully. + /// + public bool IsZoomedIn => _animator.IsZoomedIn; + + /// + /// gmCGAppearancePage::ZoomIn @ 0x0047CF00: no-op if already + /// zoomed in (retail's own early-return guard). Otherwise starts a tween + /// from the camera's CURRENT eye to the default (zoomed-IN) per-heritage + /// profile and swaps the animator to the frozen rest pose IMMEDIATELY + /// (gmCG3DView::StopAnimation's call site, pseudo-C ~0x0047d024, + /// precedes the tween's own completion by definition — it runs once, + /// synchronously, inside ZoomIn itself). + /// + public void ZoomIn() + { + if (IsZoomedIn) + return; + StartTween(ChargenPreviewCamera.ResolveDefaultEye(_heritageId)); + _animator.SetZoomedIn(true); + } + + /// + /// gmCGAppearancePage::ZoomOut @ 0x0047D050: no-op if not + /// currently zoomed in. Otherwise starts a tween toward the zoomed-OUT + /// per-heritage profile and swaps the animator back to the playing idle + /// loop immediately, mirroring . + /// + public void ZoomOut() + { + if (!IsZoomedIn) + return; + StartTween(ChargenPreviewCamera.ResolveZoomedOutEye(_heritageId)); + _animator.SetZoomedIn(false); + } + + private void StartTween(Vector3 targetEye) + { + _startEye = Camera.Eye; + _targetEye = targetEye; + _shouldAnimate = true; + _animDuration = InvalidDurationSentinel; + } + + /// + /// gmCGAppearancePage::DoZoomAnimation @ 0x0047C960: a LINEAR + /// (not eased) lerp of the eye position from m_vectStartPosition + /// to m_vectTargPosition over + /// , clamping + /// t to exactly 1.0 (and clearing m_bShouldZoomAnimate) the + /// tick that reaches or passes the duration — the decomp shows a + /// straight (targ - start) * t + start per axis with no easing + /// curve applied anywhere in this function. + /// + public void Tick(double now) + { + if (!_shouldAnimate) + return; + + if (_animDuration <= 0d) + { + _animDuration = ChargenPreviewCamera.ZoomTweenDurationSeconds; + _animStartTime = now; + } + + double elapsed = now - _animStartTime; + if (elapsed >= _animDuration) + { + _shouldAnimate = false; + elapsed = _animDuration; + } + + float t = (float)(elapsed / _animDuration); + Camera.Eye = Vector3.Lerp(_startEye, _targetEye, t); + } +} diff --git a/src/AcDream.App/Rendering/DisplayModeCatalog.cs b/src/AcDream.App/Rendering/DisplayModeCatalog.cs index 71f48f40..986b8fb0 100644 --- a/src/AcDream.App/Rendering/DisplayModeCatalog.cs +++ b/src/AcDream.App/Rendering/DisplayModeCatalog.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using AcDream.UI.Abstractions.Panels.Settings; using Silk.NET.Windowing; namespace AcDream.App.Rendering; @@ -29,13 +30,28 @@ namespace AcDream.App.Rendering; internal static class DisplayModeCatalog { private static IReadOnlyList? _resolutions; + private static IReadOnlyList? _windowedResolutions; private static string? _desktopResolution; - /// The curated list, or null when no catalog was installed - /// (fixture/headless callers — consumers fall back to the static - /// preset ladder). + /// The curated HARDWARE mode list, or null when no catalog was + /// installed (fixture/headless callers — consumers fall back to the + /// static preset ladder). This is the fullscreen mode-switch validation + /// source (#376/#388): a fullscreen pick must be a real adapter mode. public static IReadOnlyList? Resolutions => _resolutions; + /// #407: the WINDOWED size offering — the curated hardware + /// modes UNIONed with the static modern ladder entries that fit the + /// desktop. A windowed client needs no video mode (a Size write is + /// displayable at any size ≤ desktop), so gating the windowed dropdown + /// on the adapter's mode list starved remote/virtual displays whose + /// drivers advertise almost nothing (the RDP display that exposed only + /// 1920x1080 + the desktop mode, found live at the Campaign CC gate). + /// Null when no catalog was installed. The fullscreen APPLY still + /// validates against + the switcher's own + /// monitor-mode-list hard guard, so a fullscreen pick of a + /// windowed-only entry refuses safely (log-and-stay, #388/#392). + public static IReadOnlyList? WindowedResolutions => _windowedResolutions; + /// The desktop's current mode as a "WxH" string — the Config /// Resolution row's Defaults value in production (see the class doc for /// why this replaces retail's authored 800x600). Null when no catalog @@ -69,6 +85,7 @@ internal static class DisplayModeCatalog return; _resolutions = curated; + _windowedResolutions = BuildWindowedOffering(curated, (desktop.X, desktop.Y)); _desktopResolution = $"{desktop.X}x{desktop.Y}"; } @@ -76,9 +93,59 @@ internal static class DisplayModeCatalog internal static void ResetForTests() { _resolutions = null; + _windowedResolutions = null; _desktopResolution = null; } + /// + /// #407's pure union rule: the windowed offering is every curated + /// hardware mode plus every static-ladder entry that fits the desktop, + /// deduped, ascending by width then height — the same ordering + /// emits so the dropdown reads identically on + /// physical and remote displays. + /// + internal static IReadOnlyList BuildWindowedOffering( + IReadOnlyList curated, + (int W, int H) desktop) + { + var keep = new SortedSet<(int W, int H)>( + Comparer<(int W, int H)>.Create(static (a, b) => + a.W != b.W ? a.W.CompareTo(b.W) : a.H.CompareTo(b.H))); + + foreach (string spec in curated) + { + if (TryParse(spec, out (int W, int H) mode)) + keep.Add(mode); + } + foreach (string spec in DisplaySettings.AvailableResolutions) + { + if (TryParse(spec, out (int W, int H) mode) + && mode.W <= desktop.W + && mode.H <= desktop.H) + { + keep.Add(mode); + } + } + + return keep.Select(static m => $"{m.W}x{m.H}").ToArray(); + + static bool TryParse(string spec, out (int W, int H) mode) + { + mode = default; + string[] parts = spec.Split('x', 2); + if (parts.Length == 2 + && int.TryParse(parts[0], out int w) + && int.TryParse(parts[1], out int h) + && w > 0 + && h > 0) + { + mode = (w, h); + return true; + } + return false; + } + } + /// /// The pure curation rule (#391): keep a mode iff /// - it is a modern widescreen format (16:9, 16:10, or ultrawide 21:9 / diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 8b0839a1..50cc1d79 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -8,8 +8,8 @@ using AcDream.App.Settings; using AcDream.App.Platform; using AcDream.App.World; using AcDream.Content; +using AcDream.Platform; using AcDream.Runtime; -using AcDream.Runtime.Platform; using AcDream.Runtime.Entities; using AcDream.Runtime.Gameplay; using AcDream.Runtime.Session; @@ -36,6 +36,9 @@ public sealed class GameWindow : / (double)System.Diagnostics.Stopwatch.Frequency; private readonly AcDream.App.RuntimeOptions _options; + // Campaign LA slice LA1: no-op instance when --session-config didn't + // configure a statusFile (or the env-var launch path was used at all). + private readonly SessionStatusWriter _statusWriter; private readonly AnimationPresentationDiagnostics _animationDiagnostics; private readonly string _datDir; private readonly WorldGameState _worldGameState; @@ -134,6 +137,14 @@ public sealed class GameWindow : _constructionCleanup = new(); private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment; private readonly GameWindowLifetime _lifetime = new(); + // fix #406: set by Run()'s own catch the instant an exception escapes + // the Silk.NET frame loop, BEFORE it is rethrown and unwinds through + // Program.cs's `using var window = ...` (which calls Dispose() — + // therefore CompleteShutdown() — while that exception is still in + // flight). CompleteShutdown consults this so a crash is never reported + // as the hardcoded "exited{code:0,reason:graceful}" the resource + // teardown transaction's own convergence would otherwise imply. + private Exception? _runFailure; private readonly DisplayFramePacingController _displayFramePacing; private readonly RuntimeSettingsController _runtimeSettings; @@ -359,6 +370,7 @@ public sealed class GameWindow : private RuntimeActionState _runtimeActions => _runtime.ActionOwner; public AcDream.Core.Selection.SelectionState Selection => _runtimeActions.Selection; + internal SessionStatusWriter StatusWriter => _statusWriter; public AcDream.Core.Chat.ChatLog Chat => _runtimeCommunication.Chat; public AcDream.Core.Chat.TurbineChatState TurbineChat => _runtimeCommunication.TurbineChat; @@ -425,8 +437,18 @@ public sealed class GameWindow : _creatureAppraisalViewportRenderer; private AcDream.App.Rendering.CreatureAppraisalFramePresenter? _creatureAppraisalFramePresenter; + // Campaign CC slice CC6b-MOUNT — the chargen Appearance-page preview, + // same guard/shutdown shape as the paperdoll/creature-appraisal + // viewports above. + private AcDream.App.Rendering.ChargenPreviewRenderer? _chargenPreviewRenderer; + private AcDream.App.Rendering.ChargenPreviewController? _chargenPreviewController; + // Campaign CC slice CC5: the Summary page's own gmCG3DView instance — + // a SEPARATE renderer/controller pair from the Appearance preview above. + private AcDream.App.Rendering.ChargenPreviewRenderer? _summaryPreviewRenderer; + private AcDream.App.Rendering.ChargenPreviewController? _summaryPreviewController; // Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad. private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry; + private AcDream.App.Plugins.GraphicalPluginSession? _pluginSession; // Campaign V slice V11 deleted the ImGui developer-tools frontend along // with the OpenGL backend it required, so no host ever composes a // developer UI regardless of ACDREAM_DEVTOOLS. The flag still reaches @@ -615,6 +637,7 @@ public sealed class GameWindow : GraphicalHostPlatformServices platformServices) { _options = options ?? throw new System.ArgumentNullException(nameof(options)); + _statusWriter = new SessionStatusWriter(options.StatusFilePath); _platformServices = platformServices ?? throw new ArgumentNullException(nameof(platformServices)); _applicationPaths = _platformServices.Paths; @@ -718,6 +741,24 @@ public sealed class GameWindow : _movementTruthDiagnostics); } + /// + /// Transfers the graphical plugin lifetime into the window shutdown graph + /// and starts it before retained UI construction drains registrations. + /// + internal void StartPluginHosting( + AcDream.App.Plugins.GraphicalPluginSession pluginSession) + { + ArgumentNullException.ThrowIfNull(pluginSession); + if (_pluginSession is not null) + { + throw new InvalidOperationException( + "The graphical plugin session is already attached."); + } + + _pluginSession = pluginSession; + pluginSession.Start(); + } + public void Run() { _platformServices.ConfigureWindowBackend(); @@ -787,6 +828,10 @@ public sealed class GameWindow : catch (Exception failure) { _constructionCleanup.RetainFrom(failure); + // fix #406: latch BEFORE rethrowing — Dispose() (and therefore + // CompleteShutdown) can run mid-unwind of this exact exception, + // via Program.cs's `using var window = ...`. + _runFailure = failure; throw; } } @@ -1053,6 +1098,8 @@ public sealed class GameWindow : || _paperdollFramePresenter is not null || _creatureAppraisalViewportRenderer is not null || _creatureAppraisalFramePresenter is not null + || _chargenPreviewRenderer is not null + || _chargenPreviewController is not null || _envCellRenderer is not null || _envCellFrustum is not null || _landblockPresentationPipeline is not null @@ -1090,6 +1137,10 @@ public sealed class GameWindow : _paperdollFramePresenter = result.PaperdollPresenter; _creatureAppraisalViewportRenderer = result.CreatureAppraisalRenderer; _creatureAppraisalFramePresenter = result.CreatureAppraisalPresenter; + _chargenPreviewRenderer = result.ChargenPreviewRenderer; + _chargenPreviewController = result.ChargenPreviewController; + _summaryPreviewRenderer = result.SummaryPreviewRenderer; + _summaryPreviewController = result.SummaryPreviewController; _envCellFrustum = result.EnvCellFrustum; _envCellRenderer = result.EnvCellRenderer; _landblockPresentationPipeline = result.LandblockPipeline; @@ -1489,7 +1540,8 @@ public sealed class GameWindow : _combatAttackOperations, _combatFeedback, _portalTunnelFallback, - Console.WriteLine), + Console.WriteLine, + _statusWriter), this).Compose( hostInputCamera, contentEffectsAudio, @@ -1548,7 +1600,8 @@ public sealed class GameWindow : livePresentation, sessionPlayer), frameRoots => new SessionStartCompositionPhase( - new SessionStartDependencies(Console.WriteLine)) + new SessionStartDependencies( + Console.WriteLine)) .Start(frameRoots)); } @@ -1636,13 +1689,30 @@ public sealed class GameWindow : private void CompleteShutdown(bool releaseNativeWindow) { if (!_lifetime.HasShutdownRoots) + { + // Campaign LA slice LA1: capture BEFORE the shutdown roots run — + // by the time teardown completes, IsInWorld is always false + // regardless of whether a real session was ever connected. + // OnClosing() and Dispose() both funnel through this method; + // HasShutdownRoots's own guard means this fires exactly once, + // from whichever of the two reaches it first. + if (_runtime.Session.IsInWorld) + _statusWriter.Disconnected(_options.SessionId ?? "app", "stopped"); _lifetime.PublishShutdownRoots(CaptureShutdownRoots()); + } GameWindowLifetimeReport report = releaseNativeWindow ? _lifetime.CompleteAndReleaseNativeWindow() : _lifetime.TryComplete(); if (report.Status == GameWindowLifetimeStatus.Complete) + { + // "exited" = terminal — only the true Dispose() call (not the + // OnClosing() native-window-close-request pass) represents the + // process actually being done. + if (releaseNativeWindow) + ReportExited(report); return; + } Console.Error.WriteLine( $"[shutdown] status={report.Status}, blocked={report.BlockedStage ?? "none"}"); @@ -1655,6 +1725,43 @@ public sealed class GameWindow : if (report.Error is not null) Console.Error.WriteLine($"[shutdown] {report.Error}"); + + if (releaseNativeWindow) + ReportExited(report); + } + + /// + /// Writes the ONE terminal "exited" status event for this session + /// (fix #406). A resource-shutdown transaction can converge cleanly + /// ('s own + /// says nothing about this) even though this call + /// is running mid-unwind of an exception that escaped + /// 's frame loop and is about to terminate the process + /// via the CLR's unhandled-exception path — + /// is the one signal that actually distinguishes those two cases. + /// Before this fix every such crash wrote the exact same + /// "exited{code:0,reason:graceful}" as a real graceful shutdown, + /// sending any launcher-side diagnosis in the wrong direction (#406). + /// + private void ReportExited(GameWindowLifetimeReport report) + { + string sessionId = _options.SessionId ?? "app"; + if (_runFailure is not null) + { + // The real OS-level exit code (e.g. 0xE0434352 on Windows for + // an unhandled .NET exception) is produced by the runtime AFTER + // this method returns and the exception keeps propagating — it + // cannot be predicted from here. "crashed" is the truthful, + // platform-independent classification; the launcher's own + // process supervisor observes the real OS exit code separately. + _statusWriter.Exited(sessionId, 1, "crashed"); + return; + } + + if (report.Status == GameWindowLifetimeStatus.Complete) + _statusWriter.Exited(sessionId, 0, "graceful"); + else + _statusWriter.Exited(sessionId, 1, "shutdown-incomplete"); } private GameWindowShutdownRoots CaptureShutdownRoots() => new( @@ -1670,6 +1777,7 @@ public sealed class GameWindow : _kbSource, _retailUiLease, _uiHost, + _pluginSession, _runtime, _movementInput, _cameraInput, @@ -1707,6 +1815,10 @@ public sealed class GameWindow : _portalTunnelFallback, _paperdollViewportRenderer, _creatureAppraisalViewportRenderer, + _chargenPreviewRenderer, + _chargenPreviewController, + _summaryPreviewRenderer, + _summaryPreviewController, _wbDrawDispatcher, _envCellRenderer, _portalDepthMask, diff --git a/src/AcDream.App/Rendering/GameWindowLifetime.cs b/src/AcDream.App/Rendering/GameWindowLifetime.cs index 72605e26..55c27921 100644 --- a/src/AcDream.App/Rendering/GameWindowLifetime.cs +++ b/src/AcDream.App/Rendering/GameWindowLifetime.cs @@ -71,6 +71,7 @@ internal sealed record IngressShutdownRoots( RetailUiRuntimeLease RetailUi, // Keeps failed physical UI bindings alive through native-window release. UiHost? RetainedUiHost, + IDisposable? Plugins, GameRuntime Runtime, DispatcherMovementInputSource MovementInput, DispatcherCameraInputSource CameraInput, @@ -111,6 +112,16 @@ internal sealed record RenderShutdownRoots( TransferableResourceSlot PortalTunnelFallback, PaperdollViewportRenderer? Paperdoll, CreatureAppraisalViewportRenderer? CreatureAppraisal, + ChargenPreviewRenderer? ChargenPreview, + ChargenPreviewController? ChargenPreviewController, + // Campaign CC slice CC5: the Summary page's OWN gmCG3DView instance — + // same guard/shutdown shape as the Appearance-page preview above (a + // SEPARATE renderer/controller pair, not a shared one — retail's own + // gmCGSummaryPage::InitializePage @0x0047bbf0 constructs its own + // gmCG3DView, confirmed a distinct instance from the Appearance page's + // during the CC6b-MOUNT review). + ChargenPreviewRenderer? SummaryPreview, + ChargenPreviewController? SummaryPreviewController, WbDrawDispatcher? DrawDispatcher, EnvCellRenderer? EnvironmentCells, PortalDepthMaskRenderer? PortalDepthMask, @@ -422,6 +433,10 @@ internal static class GameWindowShutdownManifest Soft("keyboard source", () => DisposeKeyboardSource(ingress.KeyboardSource)), Soft("native window callbacks", () => DisposeWindowCallbacks(ingress.WindowCallbacks)), ]), + new ResourceShutdownStage("plugin host", + [ + Hard("plugins", () => ingress.Plugins?.Dispose()), + ]), new ResourceShutdownStage("frame borrowers", [ Hard("world frame composition", () => frame.FrameGraphPublication?.Dispose()), @@ -484,6 +499,10 @@ internal static class GameWindowShutdownManifest Hard( "creature appraisal viewport", () => render.CreatureAppraisal?.Dispose()), + Hard("chargen preview control", () => render.ChargenPreviewController?.Dispose()), + Hard("chargen preview viewport", () => render.ChargenPreview?.Dispose()), + Hard("summary preview control", () => render.SummaryPreviewController?.Dispose()), + Hard("summary preview viewport", () => render.SummaryPreview?.Dispose()), Hard("mesh draw dispatcher", () => render.DrawDispatcher?.Dispose()), Hard("environment cells", () => render.EnvironmentCells?.Dispose()), Hard("portal depth mask", () => render.PortalDepthMask?.Dispose()), diff --git a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs index a808e483..b2502a1a 100644 --- a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs +++ b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs @@ -335,29 +335,11 @@ internal sealed class RetailPaperdollPoseApplicator : IPaperdollPoseApplicator /// /// Retail gmPaperDollUI resolves its held pose with - /// DBCache::GetDIDFromEnumStatic(0x10000005, 7). The master map - /// therefore resolves key 7 to a sub-map, then key 0x10000005 to the - /// Animation DID. + /// DBCache::GetDIDFromEnumStatic(0x10000005, 7) — + /// parameterized by the + /// paperdoll's own fixed enum key. /// - private uint ResolvePoseDid() - { - uint masterDid = (uint)_dats.Portal.Db.Header.MasterMapId; - if (masterDid == 0 - || !_dats.Portal.TryGet( - masterDid, - out var master) - || !master.ClientEnumToID.TryGetValue(7u, out uint subDid) - || !_dats.Portal.TryGet( - subDid, - out var sub)) - { - return 0u; - } - - return sub.ClientEnumToID.TryGetValue(0x10000005u, out uint did) - ? did - : 0u; - } + private uint ResolvePoseDid() => RetailHeldPose.ResolvePoseDid(_dats, 0x10000005u); public void Apply(WorldEntity doll, uint setupId) { @@ -392,9 +374,7 @@ internal sealed class RetailPaperdollPoseApplicator : IPaperdollPoseApplicator orientation = frame.Frames[index].Orientation; } - Matrix4x4 transform = Matrix4x4.CreateScale(scale) - * Matrix4x4.CreateFromQuaternion(orientation) - * Matrix4x4.CreateTranslation(origin); + Matrix4x4 transform = RetailHeldPose.ComposePartTransform(scale, origin, orientation); MeshRef source = doll.MeshRefs[index]; reposed.Add(new MeshRef(source.GfxObjId, transform) { diff --git a/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs index 97db5e00..f293218b 100644 --- a/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs +++ b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs @@ -44,6 +44,20 @@ internal interface IPrivateEntityViewportCamera : ICamera /// raw-GL WbDrawDispatcher into (through V10, §5.5.6) was deleted at /// Campaign V slice V11: WbDrawDispatcher now records into the pass /// this renderer publishes on both call sites the same way. +/// +/// +/// Campaign CC gate round 1, Batch D (GF-7/GF-14). Retail's +/// gmCG3DView::Update @0x004EE9D0 draws a SECOND private entity — a +/// heritage-authored environment Setup (m_pbgObject) — behind the main +/// one, in the SAME creature_mode_objects list. This renderer now +/// supports that as an OPTIONAL second entity slot, reserved at construction +/// via -shaped ctor param (see below) — +/// paperdoll and creature-appraisal never pass one, so +/// throws for them rather than silently doing nothing (the slot does not +/// exist). See for +/// the full decomp citation of the backdrop's placement (unposed, at the +/// scene origin, added to the draw list BEFORE the main entity). +/// /// internal sealed class PrivateEntityViewportRenderer : IUiViewportRenderer, @@ -68,21 +82,23 @@ internal sealed class PrivateEntityViewportRenderer : private readonly WbDrawDispatcher _dispatcher; private readonly SceneLightingUboBinding _lightUbo; - private readonly FixedEntityTextureOwnerLease _textureOwnerLease; private readonly IWbMeshAdapter _meshAdapter; private readonly IPrivateEntityViewportCamera _camera; private readonly HashSet _animatedIds; private readonly string _diagnosticName; - private readonly List - _retiringMeshReferences = []; + + private readonly EntitySlot _mainSlot; + + /// Null for every renderer that never reserved a + /// backdropRenderId (paperdoll, creature-appraisal) — the backdrop + /// feature does not exist for them, not just "unused". + private readonly EntitySlot? _backdropSlot; private IGpuRenderTarget? _target; private IGpuSampler? _sampler; private GpuTextureSlot _slot = GpuTextureSlot.Unassigned; private int _fbW; private int _fbH; - private WorldEntity? _entity; - private SyntheticEntityMeshReferenceOwner? _meshReferences; public PrivateEntityViewportRenderer( IWorldPassScope scope, @@ -94,10 +110,13 @@ internal sealed class PrivateEntityViewportRenderer : IWbMeshAdapter meshAdapter, uint renderId, IPrivateEntityViewportCamera camera, - string diagnosticName) + string diagnosticName, + uint? backdropRenderId = null) { if (renderId == 0u) throw new ArgumentOutOfRangeException(nameof(renderId)); + if (backdropRenderId == 0u) + throw new ArgumentOutOfRangeException(nameof(backdropRenderId)); _scope = scope ?? throw new ArgumentNullException( nameof(scope), @@ -112,10 +131,33 @@ internal sealed class PrivateEntityViewportRenderer : _diagnosticName = string.IsNullOrWhiteSpace(diagnosticName) ? "creature viewport" : diagnosticName; - _animatedIds = [renderId]; - _textureOwnerLease = new FixedEntityTextureOwnerLease( - textureLifetime ?? throw new ArgumentNullException(nameof(textureLifetime)), - renderId); + + IEntityTextureLifetime textureLifetimeChecked = textureLifetime + ?? throw new ArgumentNullException(nameof(textureLifetime)); + + _mainSlot = new EntitySlot(_meshAdapter, textureLifetimeChecked, renderId, _diagnosticName); + _backdropSlot = backdropRenderId is uint backdropId + ? new EntitySlot(_meshAdapter, textureLifetimeChecked, backdropId, _diagnosticName + " backdrop") + : null; + + // F14 (Campaign CC gate round 1 closeout): this set is built ONCE + // here, from the RESERVED backdropRenderId (a renderer either has a + // backdrop slot or it doesn't — see _backdropSlot's own doc), not + // from whether a backdrop ENTITY is currently set via + // SetBackdrop/BuildDrawEntities. That is deliberately harmless, not + // an oversight: BuildDrawEntities below already degrades to + // [main] alone whenever the backdrop slot is null or has no + // meshes, so animatedEntityIds carrying a backdrop id with no + // matching entry in THIS frame's actual draw-entities list is a + // pure dead lookup (WbDrawDispatcher.Draw only ever consults this + // set against ids it is ACTUALLY drawing) — never a wrong-entity + // animation flag, never extra per-frame work beyond one inert + // HashSet entry. Recomputing per-frame would add real complexity + // (a second HashSet allocation or a mutable-set sync path) for a + // case that is already correct by construction. + _animatedIds = backdropRenderId is uint animatedBackdropId + ? [renderId, animatedBackdropId] + : [renderId]; } /// @@ -125,65 +167,27 @@ internal sealed class PrivateEntityViewportRenderer : /// public bool TextureIsBottomUp => false; - public void SetEntity(WorldEntity? entity) + public void SetEntity(WorldEntity? entity) => _mainSlot.Set(entity); + + /// + /// Sets or clears the environment backdrop entity drawn BEHIND the main + /// entity — GF-7/GF-14's fix, retail's gmCG3DView::m_pbgObject. Only + /// valid on a renderer constructed with a backdropRenderId + /// ('s own construction); calling this + /// on a renderer that never reserved one (paperdoll, creature-appraisal) + /// throws — the slot does not exist for them, so there is nothing to make + /// "inert" by silently ignoring the call instead. + /// + public void SetBackdrop(WorldEntity? entity) { - ReleaseRetiringMeshReferences(); - - if (ReferenceEquals(_entity, entity)) - return; - - SyntheticEntityMeshReferenceOwner? replacement = null; - if (entity is not null) + if (_backdropSlot is null) { - replacement = new SyntheticEntityMeshReferenceOwner( - _meshAdapter, - CollectMeshIds(entity)); - replacement.Acquire(); + throw new InvalidOperationException( + $"The {_diagnosticName} was not constructed with a " + + "backdropRenderId and cannot render a second (backdrop) entity."); } - SyntheticEntityMeshReferenceOwner? previous = _meshReferences; - try - { - _textureOwnerLease.Replace(entity is not null); - } - catch (Exception textureFailure) - { - if (replacement is null) - throw; - - try - { - replacement.Dispose(); - } - catch (Exception rollbackFailure) - { - throw new AggregateException( - $"The {_diagnosticName} texture-owner replacement failed " - + "and the replacement mesh-owner rollback did not converge.", - textureFailure, - rollbackFailure); - } - - System.Runtime.ExceptionServices.ExceptionDispatchInfo - .Capture(textureFailure) - .Throw(); - } - - _meshReferences = replacement; - _entity = entity; - - if (previous is not null) - { - try - { - previous.Dispose(); - } - catch - { - _retiringMeshReferences.Add(previous); - throw; - } - } + _backdropSlot.Set(entity); } /// @@ -193,7 +197,7 @@ internal sealed class PrivateEntityViewportRenderer : /// public uint Render(int width, int height) { - WorldEntity? entity = _entity; + WorldEntity? entity = _mainSlot.Entity; if (entity is null || entity.MeshRefs.Count == 0 || width <= 0 || height <= 0) return 0u; @@ -232,7 +236,7 @@ internal sealed class PrivateEntityViewportRenderer : UploadCreatureLight(); - WorldEntity[] entities = [entity]; + IReadOnlyList drawEntities = BuildDrawEntities(_backdropSlot?.Entity, entity); var entries = new (uint, Vector3, Vector3, IReadOnlyList, IReadOnlyDictionary?)[] @@ -241,7 +245,7 @@ internal sealed class PrivateEntityViewportRenderer : PrivateLandblockId, new Vector3(-1024f), new Vector3(1024f), - entities, + drawEntities, null), }; @@ -255,9 +259,34 @@ internal sealed class PrivateEntityViewportRenderer : return UiTextureTableHandle.FromSlot(_slot); } + /// + /// Pure helper assembling this frame's draw-entity list in retail's own + /// insertion order — gmCG3DView::Update adds the backdrop object to + /// creature_mode_objects BEFORE the main (player) object is + /// re-added (the player's own re-AddObject happens much later, at + /// ~0x004ef199, after the full clothing ObjDesc composes — see + /// 's own decomp + /// citation). A null or empty-meshed backdrop degrades to exactly the main + /// entity — this is the paperdoll/creature-appraisal invariant (they never + /// configure a backdrop slot at all, so this always takes this branch for + /// them), pinned directly by + /// PrivateEntityViewportRendererDrawOrderTests without needing a + /// live GPU device or a constructed . + /// + internal static IReadOnlyList BuildDrawEntities(WorldEntity? backdrop, WorldEntity main) => + backdrop is not null && backdrop.MeshRefs.Count > 0 + ? [backdrop, main] + : [main]; + /// /// Both retail paperdoll and creature examination call /// UIElement_Viewport::SetLight(DISTANT_LIGHT, 2, (0.3,1.9,0.65)). + /// Byte-decoded confirmation (Batch D re-derivation): the SAME three + /// float32 constants (0x3e99999a/0x3ff33333/0x3F266666 + /// = 0.3/1.9/0.65) appear verbatim at gmCG3DView::Update's own + /// SetLight call site (pseudo-C ~0x004eecd3-0x004eece3) — the + /// chargen preview uses the EXACT same light this method already ported, + /// not a different value. /// private void UploadCreatureLight() { @@ -342,17 +371,10 @@ internal sealed class PrivateEntityViewportRenderer : public void Dispose() { - _entity = null; - if (_meshReferences is { } current) - { - _meshReferences = null; - _retiringMeshReferences.Add(current); - } - List? failures = null; try { - _textureOwnerLease.Dispose(); + _mainSlot.Dispose(); } catch (Exception error) { @@ -360,7 +382,7 @@ internal sealed class PrivateEntityViewportRenderer : } try { - ReleaseRetiringMeshReferences(); + _backdropSlot?.Dispose(); } catch (Exception error) { @@ -391,30 +413,158 @@ internal sealed class PrivateEntityViewportRenderer : yield return entity.PartOverrides[i].GfxObjId; } - private void ReleaseRetiringMeshReferences() + /// + /// One private entity's own mesh-reference/texture-owner lifetime, + /// independent of any other slot on the same renderer. Factored out at + /// Campaign CC gate round 1 Batch D so the chargen backdrop entity gets + /// the EXACT SAME acquire/replace/retire behavior the main entity already + /// had — a single-owner class shared by both slots rather than a second, + /// hand-duplicated copy of 's + /// pre-Batch-D body. + /// + private sealed class EntitySlot { - List? failures = null; - for (int i = _retiringMeshReferences.Count - 1; i >= 0; i--) + private readonly IWbMeshAdapter _meshAdapter; + private readonly FixedEntityTextureOwnerLease _textureOwnerLease; + private readonly string _diagnosticName; + private readonly List _retiringMeshReferences = []; + + private SyntheticEntityMeshReferenceOwner? _meshReferences; + + public EntitySlot( + IWbMeshAdapter meshAdapter, + IEntityTextureLifetime textureLifetime, + uint ownerLocalId, + string diagnosticName) { - SyntheticEntityMeshReferenceOwner owner = - _retiringMeshReferences[i]; + _meshAdapter = meshAdapter; + _textureOwnerLease = new FixedEntityTextureOwnerLease(textureLifetime, ownerLocalId); + _diagnosticName = diagnosticName; + } + + public WorldEntity? Entity { get; private set; } + + public void Set(WorldEntity? entity) + { + ReleaseRetiringMeshReferences(); + + if (ReferenceEquals(Entity, entity)) + return; + + SyntheticEntityMeshReferenceOwner? replacement = null; + if (entity is not null) + { + replacement = new SyntheticEntityMeshReferenceOwner( + _meshAdapter, + CollectMeshIds(entity)); + replacement.Acquire(); + } + + SyntheticEntityMeshReferenceOwner? previous = _meshReferences; try { - owner.Dispose(); - if (owner.IsDisposed) - _retiringMeshReferences.RemoveAt(i); + _textureOwnerLease.Replace(entity is not null); + } + catch (Exception textureFailure) + { + if (replacement is null) + throw; + + try + { + replacement.Dispose(); + } + catch (Exception rollbackFailure) + { + throw new AggregateException( + $"The {_diagnosticName} texture-owner replacement failed " + + "and the replacement mesh-owner rollback did not converge.", + textureFailure, + rollbackFailure); + } + + System.Runtime.ExceptionServices.ExceptionDispatchInfo + .Capture(textureFailure) + .Throw(); + } + + _meshReferences = replacement; + Entity = entity; + + if (previous is not null) + { + try + { + previous.Dispose(); + } + catch + { + _retiringMeshReferences.Add(previous); + throw; + } + } + } + + public void Dispose() + { + Entity = null; + if (_meshReferences is { } current) + { + _meshReferences = null; + _retiringMeshReferences.Add(current); + } + + List? failures = null; + try + { + _textureOwnerLease.Dispose(); } catch (Exception error) { (failures ??= []).Add(error); } + try + { + ReleaseRetiringMeshReferences(); + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + + if (failures is not null) + { + throw new AggregateException( + $"The {_diagnosticName} resources did not fully release.", + failures); + } } - if (failures is not null) + private void ReleaseRetiringMeshReferences() { - throw new AggregateException( - $"One or more {_diagnosticName} mesh owners remain pending.", - failures); + List? failures = null; + for (int i = _retiringMeshReferences.Count - 1; i >= 0; i--) + { + SyntheticEntityMeshReferenceOwner owner = + _retiringMeshReferences[i]; + try + { + owner.Dispose(); + if (owner.IsDisposed) + _retiringMeshReferences.RemoveAt(i); + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + } + + if (failures is not null) + { + throw new AggregateException( + $"One or more {_diagnosticName} mesh owners remain pending.", + failures); + } } } } diff --git a/src/AcDream.App/Rendering/RetailHeldPose.cs b/src/AcDream.App/Rendering/RetailHeldPose.cs new file mode 100644 index 00000000..c67efe57 --- /dev/null +++ b/src/AcDream.App/Rendering/RetailHeldPose.cs @@ -0,0 +1,61 @@ +using System.Numerics; +using AcDream.Content; +using DatReaderWriter; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.Rendering; + +/// +/// Shared primitives behind retail's "resolve a rest-pose DID via master-map +/// slot 7, load its Animation, hold the final frame" algorithm — the +/// mechanism (paperdoll, +/// gmPaperDollUI::RedressCreature @ 0x004A3C22) and +/// (chargen preview, +/// gmCG3DView::StopAnimation @ 0x004EE640) both implement. Extracted +/// per the CC6a review's F11/F12 note ("before adding a FOURTH consumer... a +/// shared RetailHeldPose helper is worth extracting before a fourth +/// held-pose consumer exists") — CC6b's own idle-loop work makes chargen's +/// implementation grow enough that mechanically sharing the two primitives +/// BOTH sites already had byte-identical (DID resolution, final-frame +/// transform composition) is a clean win without forcing the two sites' +/// slightly different per-index LOOP shapes (paperdoll walks an +/// already-built, already-filtered WorldEntity.MeshRefs; chargen +/// walks the pre-filter, Setup-part-indexed scratch list) into one method +/// they don't actually share. +/// +internal static class RetailHeldPose +{ + /// + /// DBCache::GetDIDFromEnumStatic(poseEnum, 7) equivalent: master + /// map → slot 7's sub-map → 's Animation DID. + /// Returns 0 if any link in the chain is missing. MUST be called under + /// the caller's dat lock (see 's + /// datLock doc — DatCollection is not thread-safe). + /// + public static uint ResolvePoseDid(IDatReaderWriter dats, uint poseEnum) + { + uint masterDid = (uint)dats.Portal.Db.Header.MasterMapId; + if (masterDid == 0 + || !dats.Portal.TryGet(masterDid, out var master) + || !master.ClientEnumToID.TryGetValue(7u, out uint subDid) + || !dats.Portal.TryGet(subDid, out var sub)) + { + return 0u; + } + + return sub.ClientEnumToID.TryGetValue(poseEnum, out uint did) ? did : 0u; + } + + /// + /// Retail's per-part pose transform: Scale(defaultScale) * + /// Rotate(orientation) * Translate(origin) — the SAME composition + /// both RetailPaperdollPoseApplicator.Apply and + /// 's pose steps use, whether + /// the (origin, orientation) pair comes from a held final frame or an + /// interpolated idle-cycle frame. + /// + public static Matrix4x4 ComposePartTransform(Vector3 defaultScale, Vector3 origin, Quaternion orientation) => + Matrix4x4.CreateScale(defaultScale) + * Matrix4x4.CreateFromQuaternion(orientation) + * Matrix4x4.CreateTranslation(origin); +} diff --git a/src/AcDream.App/Rendering/TextRenderer.cs b/src/AcDream.App/Rendering/TextRenderer.cs index 8d64280d..c66882f4 100644 --- a/src/AcDream.App/Rendering/TextRenderer.cs +++ b/src/AcDream.App/Rendering/TextRenderer.cs @@ -201,6 +201,38 @@ public sealed class TextRenderer : IDisposable }); } + /// + /// Campaign LA gate round 2 (register AD-98): uniform canvas scale applied + /// to every emitted quad — sprites, rects, AND glyphs — at the single + /// emission chokepoint (). Retail renders its + /// fixed-canvas pre-world screens (char select's authored 800×600, root + /// 0x1000039A, zero edge anchors) at authored size and stretches the whole + /// composed frame once at presentation; its UI blitter has no stretch mode + /// at all (Graphic::Draw @0x00693b20 is copy-or-tile only). We have no + /// present-time frame stretch, so the equivalent lives here: while a + /// fixed-canvas screen is active, sets this for the + /// duration of its Draw and everything scales together — including retail's + /// characteristic non-uniform aspect distortion and stretched glyphs. + /// UVs and colors are untouched. Always reset to One outside UiRoot.Draw + /// so the world-space HUD keeps native pixels. + /// + internal Vector2 CanvasScale = Vector2.One; + + /// + /// Campaign LA gate round 2 (register AD-98 filtering fidelity): resolves a + /// UI texture handle to its linear-sampled twin + /// (), consulted by + /// only while is not One. + /// Wired once by the composition root right after TextureCache exists; + /// left null by any test/host that never sets it, in which case a scaled + /// draw keeps sampling its original slot — nearest stays nearest, exactly + /// today's (jagged) behavior, rather than throwing. Nearest-sampled dat-font + /// glyphs and composited icons are the only handles this ever changes — + /// see the resolver's own doc comment for why chrome/background art passes + /// through unchanged. + /// + internal Func? LinearTwinResolver { get; set; } + /// Begin a HUD pass. Call once per frame before any Draw* calls. public void Begin(Vector2 screenSize) { @@ -348,6 +380,15 @@ public sealed class TextRenderer : IDisposable public void DrawSprite(uint texture, float x, float y, float w, float h, float u0, float v0, float u1, float v1, Vector4 tint) { + // AD-98 filtering fidelity: while a fixed-canvas screen is stretching + // every quad (CanvasScale != One), sample nearest-registered handles + // through their linear twin instead — see LinearTwinResolver's doc + // comment. The resolver itself is the identity for any handle that + // isn't a nearest-sampled UI texture, so this is safe to call + // unconditionally rather than needing its own "is this nearest" check. + if (CanvasScale != Vector2.One && LinearTwinResolver is { } resolve) + texture = resolve(texture); + SpriteSeg seg = OverlayMode ? NextSpriteSeg(_overlaySpriteSegs, ref _overlaySegUsed, texture) : NextSpriteSeg(_spriteSegs, ref _segUsed, texture); @@ -388,10 +429,19 @@ public sealed class TextRenderer : IDisposable return ns; } - private static void AppendQuad(List buf, + private void AppendQuad(List buf, float x, float y, float w, float h, float u0, float v0, float u1, float v1, Vector4 color) { + // AD-98 canvas stretch — see CanvasScale's doc comment. Applied after + // all canvas-space clipping, so geometry and UVs stay consistent. + if (CanvasScale != Vector2.One) + { + x *= CanvasScale.X; + y *= CanvasScale.Y; + w *= CanvasScale.X; + h *= CanvasScale.Y; + } // Two triangles (6 verts). CCW in pixel space is clockwise in NDC // because the vertex shader flips Y, so OpenGL's default front-face // is GL_CCW — we rely on cull-face being disabled during HUD pass. diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index f3e18c42..7dd8a312 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -40,12 +40,33 @@ public sealed class TextureCache // Surface→SurfaceTexture chain that GetOrUpload uses for world materials. private readonly Dictionary _renderSurfaceGpuTextures = new(); + // Campaign LA gate round 2: the OTHER magenta cause GetOrUploadRenderSurface can + // hit — a non-zero id that simply isn't a RenderSurface in either dat (as opposed + // to SurfaceDecoder's own logged causes for an id that DOES resolve but can't + // decode). Same "loud, not silent" treatment, same log-once-per-id dedup pattern + // already used by EquippedChildRenderController._loggedUnaddressableParentRefusals. + private readonly HashSet _loggedMissingRenderSurfaceIds = new(); + // Ad-hoc textures produced by the public UploadRgba8(byte[],int,int,bool) wrapper // (used by IconComposer for composited item icons). These are NOT stored in any // of the keyed caches above, so Dispose must sweep this list to avoid leaking // GPU texture objects/slots until process exit. private readonly List _adhocGpuTextures = new(); + // Campaign LA gate round 2 (AD-98 filtering fidelity): the ORIGINAL IGpuTexture + // behind every handle UploadUiTexture registered nearest (dat-font glyph + // atlases, IconComposer's composited icons). Populated at upload time so + // GetOrCreateLinearUiTwin never has to search either keyed family above to + // find the pixels a twin should reuse. Chrome/background art (nearest: false) + // never enters this table — it already samples GpuSamplerDescription.WorldRepeat + // (linear) and has no twin to create. + private readonly Dictionary _nearestUiTextureSources = new(); + + // The LINEAR-sampled twin handle for a nearest handle, created lazily by + // GetOrCreateLinearUiTwin on its first request and reused after. Empty for + // the lifetime of a session that never activates a fixed-canvas screen. + private readonly Dictionary _linearUiTwinHandles = new(); + private readonly CompositeTextureArrayCache? _compositeTextures; private bool _destinationRevealUploadPriority; @@ -231,6 +252,12 @@ public sealed class TextureCache } else { + if (_loggedMissingRenderSurfaceIds.Add(renderSurfaceId)) + { + Console.WriteLine( + $"[UI] TextureCache: RenderSurface 0x{renderSurfaceId:X8} was not " + + "found in Portal or HighRes — drawing the 1x1 magenta placeholder."); + } decoded = DecodedTexture.Magenta; } @@ -346,6 +373,14 @@ public sealed class TextureCache IGpuSampler sampler = _device.CreateSampler(nearest ? UiNearestRepeat : GpuSamplerDescription.WorldRepeat); GpuTextureSlot slot = _device.RegisterTexture(texture, sampler); + uint handle = UiTextureTableHandle.FromSlot(slot); + if (nearest) + { + // AD-98 filtering fidelity: remember the source texture under its + // handle so a fixed-canvas screen can request a linear twin of it + // later without re-decoding. See GetOrCreateLinearUiTwin. + _nearestUiTextureSources[handle] = texture; + } return new GpuUiTextureEntry(texture, slot, glName, decoded.Width, decoded.Height); } catch @@ -355,6 +390,60 @@ public sealed class TextureCache } } + /// + /// Campaign LA gate round 2 (register AD-98): the LINEAR-sampled twin of a + /// nearest-sampled UI texture handle, created and table-registered the first + /// time it is requested and reused after. + /// + /// + /// Nearest is correct at the UI's native 1:1 scale — it is what makes + /// dat-font glyphs and composited item icons pixel-exact retail art. Retail's + /// own fixed-canvas pre-world screens never stretch a source texture at all: + /// they compose at authored size and the WHOLE FRAME goes through a single + /// bilinear-filtered presentation blit (see + /// 's doc comment for the + /// retail citation). acdream has no present-time frame stretch to hang that + /// on, so the equivalent has to live one step earlier, at the source texture: + /// while is scaling the composed quads + /// themselves, this method gives a nearest handle a same-pixels twin sampled + /// LINEAR instead, so the stretch softens the way retail's frame blit did + /// rather than aliasing. + /// + /// + /// + /// Returns UNCHANGED for anything this cache never + /// registered nearest — chrome/background art already samples + /// (linear) and has nothing to + /// swap, and (DrawFill's untextured + /// branch) is not a texture at all. Callers do not need to know which case + /// they're in: this is a cheap dictionary probe either way, so + /// can call it unconditionally whenever + /// the canvas is scaled. + /// + /// + /// + /// The twin reuses the ORIGINAL — no re-decode, no + /// second upload, no additional bytes tracked in the memory ledger — and + /// occupies one more device texture-table slot, exactly the shape + /// 's (surface, wrap) keying already uses to + /// register one texture under two samplers. Lazy: a session that never + /// activates a fixed-canvas screen never creates one. + /// + /// + internal uint GetOrCreateLinearUiTwin(uint handle) + { + if (!_nearestUiTextureSources.TryGetValue(handle, out IGpuTexture? texture)) + return handle; + if (_linearUiTwinHandles.TryGetValue(handle, out uint twin)) + return twin; + + IGpuSampler linearSampler = _device.CreateSampler(GpuSamplerDescription.WorldRepeat); + GpuTextureSlot twinSlot = _device.RegisterTexture(texture, linearSampler); + uint twinHandle = UiTextureTableHandle.FromSlot(twinSlot); + _linearUiTwinHandles[handle] = twinHandle; + return twinHandle; + } + /// /// The identity a UI upload is accounted under. There is no GL name on the /// Vulkan-only backend, so a descending synthetic counter supplies one; the @@ -981,6 +1070,15 @@ public sealed class TextureCache _paletteIndexedByTexture.Clear(); + // Campaign LA gate round 2 (AD-98): linear twin slots. Each one is a + // SECOND table registration of a texture another family below owns and + // disposes — release the slot here, before that texture goes away, and + // never touch the texture itself (that would double-dispose it). + foreach (uint twinHandle in _linearUiTwinHandles.Values) + _device.ReleaseTextureSlot(UiTextureTableHandle.ToSlot(twinHandle)); + _linearUiTwinHandles.Clear(); + _nearestUiTextureSources.Clear(); + // RenderSurface (UI sprite) textures — Campaign V slice V4a: each // entry's IGpuTexture.Dispose() releases the underlying GL name // through the device's own retirement queue, so only the memory- diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs index e11df45d..76ed43e5 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs @@ -1,5 +1,6 @@ using AcDream.App.Interaction; using AcDream.App.Net; +using AcDream.Core.CharGen; using AcDream.Runtime; using AcDream.Runtime.Session; using AcDream.Runtime.World; @@ -20,6 +21,8 @@ internal sealed class CurrentGameRuntimeAdapter { private readonly GameRuntime _runtime; private readonly CurrentGameRuntimeCommandAdapter _commands; + private readonly CharacterSelectionProjection _characterSelection; + private readonly CharacterCreationProjection _characterCreation; private readonly IDisposable _hostLease; private readonly object _subscriptionGate = new(); private readonly HashSet _subscriptions = []; @@ -40,6 +43,8 @@ internal sealed class CurrentGameRuntimeAdapter "graphical game-runtime command adapter"); try { + _characterSelection = new CharacterSelectionProjection(this); + _characterCreation = new CharacterCreationProjection(this); _commands = new CurrentGameRuntimeCommandAdapter( runtime.Session, sessionHost, @@ -61,7 +66,7 @@ internal sealed class CurrentGameRuntimeAdapter } private bool IsActive => - !_disposed + !Volatile.Read(ref _disposed) && !_runtime.Session.IsDisposalComplete; public RuntimeGenerationToken Generation => _runtime.Generation; @@ -89,6 +94,10 @@ internal sealed class CurrentGameRuntimeAdapter public IRuntimeCharacterView Character => _runtime.Character; public IRuntimeSocialView Social => _runtime.Social; public IRuntimeChatView Chat => _runtime.Chat; + public IRuntimeCharacterSelectionView CharacterSelection => + _characterSelection; + public IRuntimeCharacterCreationView CharacterCreation => + _characterCreation; public IRuntimeFellowshipView Fellowship => _runtime.Fellowship; public IRuntimeAllegianceView Allegiance => _runtime.Allegiance; public IRuntimeActionView Actions => _runtime.Actions; @@ -97,6 +106,14 @@ internal sealed class CurrentGameRuntimeAdapter public IRuntimePortalView Portal => _runtime.Portal; public IRuntimeSessionCommands Session => _commands; + public IRuntimeCharacterSelectionCommands CharacterSelectionCommands => + _characterSelection; + IRuntimeCharacterSelectionCommands IGameRuntimeCommands.CharacterSelection => + _characterSelection; + public IRuntimeCharacterCreationCommands CharacterCreationCommands => + _characterCreation; + IRuntimeCharacterCreationCommands IGameRuntimeCommands.CharacterCreation => + _characterCreation; public IRuntimeSelectionCommands Selection => _commands; public IRuntimeCombatCommands Combat => _commands; public IRuntimeMagicCommands Magic => _commands; @@ -160,6 +177,400 @@ internal sealed class CurrentGameRuntimeAdapter _subscriptions.Remove(subscription); } + private RuntimeCharacterSelectionSnapshot CharacterSelectionSnapshot() + { + lock (_subscriptionGate) + { + if (IsActive) + return _runtime.CharacterSelection.Snapshot; + return new RuntimeCharacterSelectionSnapshot( + _runtime.Generation, + RuntimeCharacterSelectionLifecycle.Inactive, + Revision: 0, + AccountName: string.Empty, + SlotCount: 0, + RosterCount: 0, + WorldName: string.Empty, + HighlightedCharacterId: 0u, + HighlightedDisplayIndex: -1, + PendingDeleteCharacterId: 0u, + LastRestoreRequestedCharacterId: 0u, + Operation: RuntimeCharacterSelectionOperation.None, + Error: null, + Buttons: RuntimeCharacterSelectionButtons.None); + } + } + + private bool TryGetCharacterSelectionAt( + int displayIndex, + out RuntimeCharacterSelectionEntry character) + { + lock (_subscriptionGate) + { + if (IsActive) + { + return _runtime.CharacterSelection.TryGetAt( + displayIndex, + out character); + } + character = default; + return false; + } + } + + private bool TryGetCharacterSelection( + uint characterId, + out RuntimeCharacterSelectionEntry character) + { + lock (_subscriptionGate) + { + if (IsActive) + { + return _runtime.CharacterSelection.TryGet( + characterId, + out character); + } + character = default; + return false; + } + } + + private void VisitCharacterSelection( + IRuntimeCharacterSelectionVisitor visitor) + { + ArgumentNullException.ThrowIfNull(visitor); + lock (_subscriptionGate) + { + if (IsActive) + _runtime.CharacterSelection.Visit(visitor); + } + } + + private IDisposable SubscribeCharacterSelection( + IRuntimeCharacterSelectionObserver observer) + { + ArgumentNullException.ThrowIfNull(observer); + lock (_subscriptionGate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + var gated = new AdapterCharacterSelectionObserver(this, observer); + IDisposable runtimeSubscription = + _runtime.CharacterSelection.Subscribe(gated); + var subscription = new AdapterSubscription( + this, + runtimeSubscription); + _subscriptions.Add(subscription); + return subscription; + } + } + + private RuntimeCommandResult ExecuteCharacterSelection( + Func execute) + { + lock (_subscriptionGate) + { + if (!IsActive) + { + return new RuntimeCommandResult( + RuntimeCommandStatus.Inactive, + _runtime.Generation); + } + return execute(_runtime.Session); + } + } + + private void ForwardCharacterSelection( + IRuntimeCharacterSelectionObserver observer, + in RuntimeCharacterSelectionDelta delta) + { + lock (_subscriptionGate) + { + if (IsActive) + observer.OnCharacterSelectionChanged(in delta); + } + } + + // ── Campaign CC slice CC4: character creation, same shape as the + // character-selection block above. ──────────────────────────────── + + private RuntimeCharacterCreationSnapshot CharacterCreationSnapshot() + { + lock (_subscriptionGate) + { + if (IsActive) + return _runtime.CharacterCreation.Snapshot; + return default; + } + } + + private ChargenSkillAdvancementClass CharacterCreationSkillLevel(uint skillId) + { + lock (_subscriptionGate) + { + return IsActive + ? _runtime.CharacterCreation.GetSkillLevel(skillId) + : ChargenSkillAdvancementClass.Inactive; + } + } + + private ChargenOptions CharacterCreationOptions() + { + lock (_subscriptionGate) + { + return IsActive + ? _runtime.CharacterCreation.Options + : ChargenOptions.Empty; + } + } + + private IDisposable SubscribeCharacterCreation( + IRuntimeCharacterCreationObserver observer) + { + ArgumentNullException.ThrowIfNull(observer); + lock (_subscriptionGate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + var gated = new AdapterCharacterCreationObserver(this, observer); + IDisposable runtimeSubscription = + _runtime.CharacterCreation.Subscribe(gated); + var subscription = new AdapterSubscription( + this, + runtimeSubscription); + _subscriptions.Add(subscription); + return subscription; + } + } + + private RuntimeCommandResult ExecuteCharacterCreation( + Func execute) + { + lock (_subscriptionGate) + { + if (!IsActive) + { + return new RuntimeCommandResult( + RuntimeCommandStatus.Inactive, + _runtime.Generation); + } + return execute(_runtime.Session); + } + } + + private void ForwardCharacterCreation( + IRuntimeCharacterCreationObserver observer, + in RuntimeCharacterCreationDelta delta) + { + lock (_subscriptionGate) + { + if (IsActive) + observer.OnCharacterCreationChanged(in delta); + } + } + + private sealed class CharacterSelectionProjection( + CurrentGameRuntimeAdapter owner) + : IRuntimeCharacterSelectionView, + IRuntimeCharacterSelectionCommands + { + public RuntimeCharacterSelectionSnapshot Snapshot => + owner.CharacterSelectionSnapshot(); + + public bool TryGetAt( + int displayIndex, + out RuntimeCharacterSelectionEntry character) => + owner.TryGetCharacterSelectionAt(displayIndex, out character); + + public bool TryGet( + uint characterId, + out RuntimeCharacterSelectionEntry character) => + owner.TryGetCharacterSelection(characterId, out character); + + public void Visit(IRuntimeCharacterSelectionVisitor visitor) => + owner.VisitCharacterSelection(visitor); + + public IDisposable Subscribe( + IRuntimeCharacterSelectionObserver observer) => + owner.SubscribeCharacterSelection(observer); + + public RuntimeCommandResult Highlight( + RuntimeGenerationToken expectedGeneration, + uint characterId) => + owner.ExecuteCharacterSelection( + commands => commands.Highlight( + expectedGeneration, + characterId)); + + public RuntimeCommandResult Enter( + RuntimeGenerationToken expectedGeneration) => + owner.ExecuteCharacterSelection( + commands => commands.Enter(expectedGeneration)); + + public RuntimeCommandResult RequestDelete( + RuntimeGenerationToken expectedGeneration) => + owner.ExecuteCharacterSelection( + commands => commands.RequestDelete(expectedGeneration)); + + public RuntimeCommandResult ConfirmDelete( + RuntimeGenerationToken expectedGeneration) => + owner.ExecuteCharacterSelection( + commands => commands.ConfirmDelete(expectedGeneration)); + + public RuntimeCommandResult Restore( + RuntimeGenerationToken expectedGeneration) => + owner.ExecuteCharacterSelection( + commands => commands.Restore(expectedGeneration)); + + public RuntimeCommandResult Cancel( + RuntimeGenerationToken expectedGeneration) => + owner.ExecuteCharacterSelection( + commands => commands.Cancel(expectedGeneration)); + } + + private sealed class AdapterCharacterSelectionObserver( + CurrentGameRuntimeAdapter owner, + IRuntimeCharacterSelectionObserver observer) + : IRuntimeCharacterSelectionObserver + { + public void OnCharacterSelectionChanged( + in RuntimeCharacterSelectionDelta delta) => + owner.ForwardCharacterSelection(observer, in delta); + } + + private sealed class CharacterCreationProjection( + CurrentGameRuntimeAdapter owner) + : IRuntimeCharacterCreationView, + IRuntimeCharacterCreationCommands + { + public RuntimeCharacterCreationSnapshot Snapshot => + owner.CharacterCreationSnapshot(); + + public ChargenSkillAdvancementClass GetSkillLevel(uint skillId) => + owner.CharacterCreationSkillLevel(skillId); + + public ChargenOptions Options => owner.CharacterCreationOptions(); + + public IDisposable Subscribe(IRuntimeCharacterCreationObserver observer) => + owner.SubscribeCharacterCreation(observer); + + public RuntimeCommandResult SelectHeritage( + RuntimeGenerationToken expectedGeneration, + uint heritageId) => + owner.ExecuteCharacterCreation( + commands => commands.SelectHeritage(expectedGeneration, heritageId)); + + public RuntimeCommandResult SelectGender( + RuntimeGenerationToken expectedGeneration, + uint genderKey) => + owner.ExecuteCharacterCreation( + commands => commands.SelectGender(expectedGeneration, genderKey)); + + public RuntimeCommandResult SelectTemplate( + RuntimeGenerationToken expectedGeneration, + uint templateIndex) => + owner.ExecuteCharacterCreation( + commands => commands.SelectTemplate(expectedGeneration, templateIndex)); + + public RuntimeCommandResult SetAttribute( + RuntimeGenerationToken expectedGeneration, + ChargenAttributeId attributeId, + int value) => + owner.ExecuteCharacterCreation( + commands => commands.SetAttribute(expectedGeneration, attributeId, value)); + + public RuntimeCommandResult SetAttributeLock( + RuntimeGenerationToken expectedGeneration, + ChargenAttributeId attributeId, + bool locked) => + owner.ExecuteCharacterCreation( + commands => commands.SetAttributeLock(expectedGeneration, attributeId, locked)); + + public RuntimeCommandResult TrainSkill( + RuntimeGenerationToken expectedGeneration, + uint skillId) => + owner.ExecuteCharacterCreation( + commands => commands.TrainSkill(expectedGeneration, skillId)); + + public RuntimeCommandResult SpecializeSkill( + RuntimeGenerationToken expectedGeneration, + uint skillId) => + owner.ExecuteCharacterCreation( + commands => commands.SpecializeSkill(expectedGeneration, skillId)); + + public RuntimeCommandResult UntrainSkill( + RuntimeGenerationToken expectedGeneration, + uint skillId) => + owner.ExecuteCharacterCreation( + commands => commands.UntrainSkill(expectedGeneration, skillId)); + + public RuntimeCommandResult SetAppearanceIndex( + RuntimeGenerationToken expectedGeneration, + ChargenAppearanceSlot slot, + uint index) => + owner.ExecuteCharacterCreation( + commands => commands.SetAppearanceIndex(expectedGeneration, slot, index)); + + public RuntimeCommandResult SetShade( + RuntimeGenerationToken expectedGeneration, + ChargenShadeSlot slot, + double value) => + owner.ExecuteCharacterCreation( + commands => commands.SetShade(expectedGeneration, slot, value)); + + public RuntimeCommandResult SelectStartArea( + RuntimeGenerationToken expectedGeneration, + int startAreaIndex) => + owner.ExecuteCharacterCreation( + commands => commands.SelectStartArea(expectedGeneration, startAreaIndex)); + + public RuntimeCommandResult SetName( + RuntimeGenerationToken expectedGeneration, + string name) => + owner.ExecuteCharacterCreation( + commands => commands.SetName(expectedGeneration, name)); + + public RuntimeCommandResult SetSlot( + RuntimeGenerationToken expectedGeneration, + uint slot) => + owner.ExecuteCharacterCreation( + commands => commands.SetSlot(expectedGeneration, slot)); + + public RuntimeCommandResult Finish( + RuntimeGenerationToken expectedGeneration, + bool confirmUnspentCredits = false) => + owner.ExecuteCharacterCreation( + commands => commands.Finish(expectedGeneration, confirmUnspentCredits)); + + public RuntimeCommandResult AcknowledgeRejection( + RuntimeGenerationToken expectedGeneration) => + owner.ExecuteCharacterCreation( + commands => commands.AcknowledgeRejection(expectedGeneration)); + + public RuntimeCommandResult RandomizeCharacter( + RuntimeGenerationToken expectedGeneration) => + owner.ExecuteCharacterCreation( + commands => commands.RandomizeCharacter(expectedGeneration)); + + public RuntimeCommandResult RandomizeAppearance( + RuntimeGenerationToken expectedGeneration) => + owner.ExecuteCharacterCreation( + commands => commands.RandomizeAppearance(expectedGeneration)); + + public RuntimeCommandResult RandomizeClothing( + RuntimeGenerationToken expectedGeneration) => + owner.ExecuteCharacterCreation( + commands => commands.RandomizeClothing(expectedGeneration)); + } + + private sealed class AdapterCharacterCreationObserver( + CurrentGameRuntimeAdapter owner, + IRuntimeCharacterCreationObserver observer) + : IRuntimeCharacterCreationObserver + { + public void OnCharacterCreationChanged( + in RuntimeCharacterCreationDelta delta) => + owner.ForwardCharacterCreation(observer, in delta); + } + private sealed class AdapterSubscription( CurrentGameRuntimeAdapter owner, IDisposable runtimeSubscription) : IDisposable diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 703a14a3..1d69d3e9 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -1,8 +1,13 @@ using System; +using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Reflection; +using System.Text; +using AcDream.App.Configuration; using AcDream.App.Rendering.Residency; using AcDream.App.Streaming; +using AcDream.Runtime.Session; namespace AcDream.App; @@ -48,6 +53,12 @@ public sealed record RuntimeOptions( bool DumpClothing, int? LegacyStreamRadius, bool RetailUi, + /// Campaign CC slice CC4: interim env/test-only seam that opens + /// the character-creation screen once Runtime's chargen view goes + /// active — the real transition is retail's Create Character button + /// (0x100003A0), which stays ghosted until CC7's closing move. + /// See CharacterCreationRuntimeBindings.OpenOnStart. + bool OpenCharacterCreationOnStart, string? AcDir, bool UiProbeDump, string? UiProbeScript, @@ -62,7 +73,34 @@ public sealed record RuntimeOptions( string? VulkanDeviceOverride, string? VulkanForcedUnsupportedFeature, bool VulkanCapabilityProbe, - int VulkanCapabilityProbeFrames) + int VulkanCapabilityProbeFrames, + /// Campaign LA slice LA1: the raw --session-config path, + /// or when the flag was not supplied (the env-var + /// dev flow). Kept for diagnostics/logging only. + string? SessionConfigPath, + /// Campaign LA slice LA1: the configured session's id, used as + /// the sessionId field on every status-stream event. Defaults to + /// "app" at every call site when unset (env-var flow). + string? SessionId, + /// Campaign LA slice LA1: the session-config character + /// selector, or for today's existing + /// first-available fallback (absent selector = LA7's char-select screen + /// stop point once that slice lands; this slice does not build the + /// screen). + LiveSessionCharacterSelector? LiveCharacterSelector, + /// Campaign LA slice LA1: absolute path for the status-event + /// JSONL stream. = no writer constructed. + string? StatusFilePath, + /// Campaign LA slice LA1: plugin ids to load. + /// = load every discovered plugin (today's + /// behavior). Consumed by the shared graphical plugin session. + IReadOnlyList? Plugins, + /// Campaign LA slice LA1: ordered chat-typed strings run once + /// entered-world through the shared Runtime parser/router. + IReadOnlyList LoginCommands, + /// Campaign LA slice LA1: inter-command delay for + /// , milliseconds. + int LoginCommandDelayMs) { /// /// Build options from the process environment. Used by @@ -114,6 +152,8 @@ public sealed record RuntimeOptions( // top of the quality preset's radii. Null when unset or invalid. LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")), RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")), + OpenCharacterCreationOnStart: + IsExactlyOne(env("ACDREAM_OPEN_CHARGEN")), AcDir: NullIfEmpty(env("ACDREAM_AC_DIR")), UiProbeDump: IsExactlyOne(env("ACDREAM_UI_PROBE_DUMP")), UiProbeScript: NullIfEmpty(env("ACDREAM_UI_PROBE_SCRIPT")), @@ -170,7 +210,116 @@ public sealed record RuntimeOptions( // closes the window. Zero -- unset, unparseable, or an explicit 0 -- // keeps the interactive behaviour, so no existing invocation changes. VulkanCapabilityProbeFrames: - TryParseNonNegativeInt(env("ACDREAM_VULKAN_PROBE_FRAMES")) ?? 0); + TryParseNonNegativeInt(env("ACDREAM_VULKAN_PROBE_FRAMES")) ?? 0, + // Campaign LA slice LA1: the env-var dev flow never carries a + // session-config document — every new field below stays at its + // "nothing configured" default. RuntimeOptions.FromSessionConfig + // overlays the real values on top of this base. + SessionConfigPath: null, + SessionId: null, + LiveCharacterSelector: null, + StatusFilePath: null, + Plugins: null, + LoginCommands: [], + LoginCommandDelayMs: 500); + } + + /// + /// Campaign LA slice LA1: builds options for the --session-config + /// launch path. Starts from the same env-var parse as + /// (diagnostic/dev flags are still + /// env-controlled — only the LIVE session settings and the five new LA1 + /// fields come from the document) and overlays the resolved session. + /// is revealed into + /// exactly as wide as the existing env-var flow — + /// see that field's own doc. + /// + internal static RuntimeOptions FromSessionConfig( + string datDir, + Func env, + string sessionConfigPath, + SessionConfiguration config, + SessionDescriptor session, + string? resolvedPassword) + { + if (config is null) throw new ArgumentNullException(nameof(config)); + if (session is null) throw new ArgumentNullException(nameof(session)); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionConfigPath); + + RuntimeOptions baseOptions = Parse(datDir, env); + SessionContentDescriptor? content = config.Process?.Content; + return baseOptions with + { + PreparedAssetPath = NullIfEmpty(content?.PreparedAssetPath) + ?? baseOptions.PreparedAssetPath, + 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. + // ACDREAM_RETAIL_UI remains the opt-in for env-var dev launches, + // but the launcher strips ACDREAM_* from children (LA11 isolation), + // so inheriting the env default here shipped a client with world + // rendering and NO interface at all — the guiSelect flow's + // character screen included. + RetailUi = true, + LiveHost = session.Endpoint.Host, + LivePort = session.Endpoint.Port, + LiveUser = session.Account, + LivePass = resolvedPassword, + SessionConfigPath = sessionConfigPath, + SessionId = session.Id, + LiveCharacterSelector = MapCharacterSelector(session.Character), + StatusFilePath = NullIfEmpty(session.StatusFile), + Plugins = session.Plugins, + LoginCommands = (IReadOnlyList?)session.LoginCommands ?? [], + LoginCommandDelayMs = session.LoginCommandDelayMs, + }; + } + + private static LiveSessionCharacterSelector? MapCharacterSelector( + SessionCharacterSelectorDescriptor? selector) => + selector is null + ? null + : new LiveSessionCharacterSelector( + selector.Index, + selector.Id, + selector.Name); + + private static readonly PropertyInfo[] PrintableProperties = + typeof(RuntimeOptions) + .GetProperties( + BindingFlags.Instance + | BindingFlags.Public + | BindingFlags.DeclaredOnly) + .Where(static property => + property.GetMethod is not null + && property.GetIndexParameters().Length == 0) + .OrderBy(static property => property.MetadataToken) + .ToArray(); + + /// + /// Campaign LA LA1 defense in depth: positional records normally print + /// every public property, including the live password. Preserve that + /// ordinary diagnostic property set while substituting the one sensitive + /// value before it can reach a log, debugger display, or exception. + /// Reflection is cached once and runs only on the diagnostic + /// path. + /// + private bool PrintMembers(StringBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + for (int index = 0; index < PrintableProperties.Length; index++) + { + PropertyInfo property = PrintableProperties[index]; + if (index != 0) + builder.Append(", "); + builder.Append(property.Name); + builder.Append(" = "); + builder.Append( + property.Name == nameof(LivePass) && LivePass is not null + ? "" + : property.GetValue(this)); + } + return PrintableProperties.Length != 0; } /// True iff live-mode credentials are present and valid for connecting. diff --git a/src/AcDream.App/Settings/RuntimeSettingsTargets.cs b/src/AcDream.App/Settings/RuntimeSettingsTargets.cs index c5b77d05..2f90d34d 100644 --- a/src/AcDream.App/Settings/RuntimeSettingsTargets.cs +++ b/src/AcDream.App/Settings/RuntimeSettingsTargets.cs @@ -87,12 +87,23 @@ internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarg : this( new SilkWindowSizeSurface(window), new GlfwDisplayModeSwitcher(window), - // #391's catalog is the validation source. With no catalog - // installed, the dropdown falls back to the static preset - // ladder — the validator must fall back to the SAME list - // (blast M2: an asymmetric fallback made Full Screen a permanent - // silent no-op on catalog-less hosts). The switcher's own - // monitor-mode-list check remains the hard guard either way. + // F9 correction (Campaign CC gate round 1 closeout, 2026-08-16): + // this used to claim the validator "must fall back to the SAME + // list" the Config dropdown offers — true when this comment was + // written (#391, one catalog for both), but #407 split the + // catalog in two: WindowedResolutions (the dropdown's fuller + // union offering, since a windowed pick needs no real video + // mode) versus Resolutions (the narrower, fullscreen-SAFE + // hardware list this validator deliberately reads). Post-#407 a + // windowed-only entry submitted for fullscreen is EXPECTED to + // fail this check and refuse gracefully (log-and-stay, + // #388/#392's own documented behavior) — that is no longer the + // blast-M2 silent-no-op bug, it is the correct outcome. With no + // catalog installed at all (fixture/headless/UI-Studio hosts), + // Resolutions is null and this still falls back to the static + // preset ladder, matching every offering DisplayModeCatalog + // makes in that state. The switcher's own monitor-mode-list + // check remains the hard guard either way. spec => (Rendering.DisplayModeCatalog.Resolutions ?? DisplaySettings.AvailableResolutions).Contains(spec)) { diff --git a/src/AcDream.App/UI/GameplayConfirmationController.cs b/src/AcDream.App/UI/GameplayConfirmationController.cs index 75758734..4e36cfa7 100644 --- a/src/AcDream.App/UI/GameplayConfirmationController.cs +++ b/src/AcDream.App/UI/GameplayConfirmationController.cs @@ -58,8 +58,9 @@ public sealed class GameplayConfirmationController : IDisposable ? request.Message + " Continue?" : _composeMessage?.Invoke(request.Type, request.Message) ?? request.Message; - var data = RetailDialogData.Confirmation(message) - .Set(RetailDialogProperty.ElementAttribute40, true); + // ElementAttribute40 now comes from RetailDialogData.Confirmation + // itself (batch review F5 — retail's confirmation builders all set it). + var data = RetailDialogData.Confirmation(message); _dialogContext = _dialogs.MakeDialog(data); return _dialogContext != 0u; } diff --git a/src/AcDream.App/UI/IUiDatStateful.cs b/src/AcDream.App/UI/IUiDatStateful.cs index 7a86e9b8..da1b0437 100644 --- a/src/AcDream.App/UI/IUiDatStateful.cs +++ b/src/AcDream.App/UI/IUiDatStateful.cs @@ -24,6 +24,22 @@ public static class RetailUiStateIds public const uint LockedUi = 0x10000063u; public const uint UnlockedUi = 0x10000064u; + /// + /// Campaign CC gate round 1 Batch B (GF-1/GF-8): retail's custom + /// radio-selection state pair, live-DAT-probe-confirmed on the Heritage + /// row (0x100003BF), Profession template (0x100003D9), + /// Appearance Face/Clothes sub-tabs (0x100003A9/0x100003AA), + /// and gender buttons (0x100003A7/0x100003A8). Named + /// UiStateInfo.Name strings, not media file ids — the buttons + /// author their state DESCRIPTORS under these two ids, with the actual + /// per-state art living either directly on the button (gender) or on a + /// single stateful face-segment child (heritage/template/sub-tabs). + /// See 's custom-selection-pair + /// bypass in UpdateVisualState. + /// + public const uint Unselected = 0x10000016u; + public const uint Selected = 0x10000017u; + public static string StateName(uint stateId) => stateId switch { @@ -38,6 +54,8 @@ public static class RetailUiStateIds Minimized => "Minimized", LockedUi => "LockedUI", UnlockedUi => "UnlockedUI", + Unselected => "Unselected", + Selected => "Selected", _ => "", }; @@ -56,6 +74,8 @@ public static class RetailUiStateIds "Minimized" => Minimized, "LockedUI" => LockedUi, "UnlockedUI" => UnlockedUi, + "Unselected" => Unselected, + "Selected" => Selected, _ => 0u, }; return stateId != 0; diff --git a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs new file mode 100644 index 00000000..7929129b --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs @@ -0,0 +1,1335 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.Core.CharGen; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.App.UI.Layout; + +/// +/// The Appearance page (gmCGAppearancePage, root 0x100003d4) — +/// Campaign CC slice CC6b-MOUNT, the final piece of CC6. Decomp anchors: +/// gmCGAppearancePage::InitializePage @ 0x0047FDD0 (widget ids, the +/// 180° initial heading), ::ListenToElementMessage @ 0x0047EF30 (EVERY +/// dispatch this page implements — gender buttons at cases 0x9d/ +/// 0x9e; Face/Clothes sub-tabs at 0x9f/0xa0; the nine +/// spin controls' decrement/increment arrow children, keyed by PARENT id, at +/// cases 0/1; the same nine spins' own BODY click (select-as- +/// current-part, no index change) at cases 0xa5-0xa9 and the +/// mirrored headgear/shirt/trousers/footwear cases; the nine color swatches +/// at cases 5-0xd -> SetColor(0..8); the shade +/// scrollbar at case 0x17 -> SetShade; rotate at +/// 0x19/0x1a; zoom at 0x1b/0x1c), +/// ::SetColor @ 0x0047DD50 and ::SetShade @ 0x0047C860 (the +/// per-part color/shade routing table this page's switch +/// mirrors, including the NOSE/MOUTH/SKIN-all-route-to-skin-shade quirk and +/// EYES having no shade at all), ::Update @ 0x0047E8F0 (the heritage +/// 6/0xc/0xd Clothes-button + Nose/Mouth-spin hide, Eyes-arrows-disable +/// gate). +/// +/// +/// Spin arrow geometry (live-DAT probe, CC6b-MOUNT): every one of the +/// nine spin elements (0x100003af-b3, b5-b8) is uniformly +/// 200px wide with two LOCALLY-reused arrow child ids (0x1000030a +/// decrement at local x=[80,127), 0x1000030b increment at +/// x=[127,174)) that DatWidgetFactory's UiButton consumes into +/// one flat clickable leaf — there is no separate addressable arrow widget +/// to bind. This page reproduces retail's two-arrow-plus-body-click shape +/// entirely through 's local x coordinate +/// (no new DatWidgetFactory widget type needed — see this campaign's +/// color-wheel scouting finding below, which reached the identical "existing +/// types suffice" conclusion for the whole page). +/// +/// +/// +/// Color-wheel family scouting (campaign plan risk item 4, RESOLVED): +/// a live-DAT probe (CharacterCreationLiveDatTests) found every +/// color-wheel-family id resolves through EXISTING DatWidgetFactory +/// mappings: the nine swatch buttons (0x1000030f-0x10000317, retail's +/// SetColor(0..8) targets) author Type 1 -> UiButton; their +/// nine Type-3 companion "selected" overlays (0x10000318-0x10000320, +/// retail's m_tColorWheel[...][0x10][iCurColor*7]->SetVisible +/// highlight ring) and the GradCircle (0x1000030e) author Type 3 -> +/// UiDatElement; the shade scrollbar (0x10000321) authors +/// Type 0xB -> UiScrollbar, matching the decomp's own +/// DynamicCast(0xb). NO new widget type was added. This page uses the +/// swatch buttons' own state for the +/// highlight instead of toggling the separate companion overlay elements — +/// a documented substitution (same class as AD-103's swallowed-child +/// pattern), not a pixel-identical port of retail's own two-widget +/// mechanism. +/// +/// +/// +/// Icon-only style lists have no name string (register-worthy scope +/// cut): CC1's // +/// carry an IconId, not a name — retail +/// shows an actual icon thumbnail in these four spins (hair/eyes/nose/ +/// mouth). Icon rendering is out of this round's scope; the spin shows a +/// 1-based ordinal instead. The four clothing spins (headgear/shirt/ +/// trousers/footwear) DO carry a real +/// and show it directly. +/// +/// +/// +/// The real color wheel (Campaign CC gate round 1 Batch G, R2-5, +/// register AP-216/AP-217 — CLOSEOUT (Group 1) makes it visually live): +/// retail's DoColorSpots @0x0047d850 / DoGradDisk @0x0047da90 +/// paint each swatch and the gradient disc with an ACTUAL representative +/// color sampled from the real DAT palette data +/// (AcDream.Core.CharGen.ChargenSwatchColorResolver ports the +/// computation — see its own doc for the two color-source shapes and the +/// clothing PalSet lookup) via a genuine multiplicative sprite tint — +/// retail's own SurfaceWindow::BlitAndColor(..., Blit_Multiply, +/// color). // +/// are late-bound composition seams (same +/// pattern as ) a DAT-backed catalog wires in +/// after construction (CharacterCreationUiController.AppearancePalSetSource +/// etc., assigned once by LivePresentationComposition alongside the +/// existing AppearancePreviewControl wiring). Each swatch +/// () and the gradient disc (, +/// resolved via ) now set their OWN +/// / directly — +/// the earlier flat-fill ChargenSwatchColorTile overlay (Batch G's +/// documented approximation, since neither widget exposed a tint hook yet) +/// is retired: a flat opaque rectangle drawn ON TOP of a sprite can never +/// reproduce a multiply blend, only a genuine per-instance sprite tint can, +/// so this closeout replaces the overlay outright rather than layering a +/// tint UNDER it. +/// +/// +internal sealed class CharacterCreationAppearancePage : IDisposable +{ + private const uint Unset = RuntimeCharacterCreationAppearance.Unset; + + internal enum Part + { + Hair = 1, + Eyes = 2, + Nose = 3, + Mouth = 4, + Skin = 5, + Headgear = 6, + Shirt = 7, + Trousers = 8, + Footwear = 9, + } + + private enum Choice + { + Face, + Clothes, + } + + internal const uint FemaleButtonId = 0x100003A7u; + internal const uint MaleButtonId = 0x100003A8u; + internal const uint FaceButtonId = 0x100003A9u; + internal const uint ClothesButtonId = 0x100003AAu; + internal const uint FaceChoicesId = 0x100003AEu; + internal const uint ClothesChoicesId = 0x100003B4u; + internal const uint HairSpinId = 0x100003AFu; + internal const uint EyesSpinId = 0x100003B0u; + internal const uint NoseSpinId = 0x100003B1u; + internal const uint MouthSpinId = 0x100003B2u; + internal const uint SkinSpinId = 0x100003B3u; + internal const uint HeadgearSpinId = 0x100003B5u; + internal const uint ShirtSpinId = 0x100003B6u; + internal const uint TrousersSpinId = 0x100003B7u; + internal const uint FootwearSpinId = 0x100003B8u; + internal const uint RotateClockwiseId = 0x10000323u; + internal const uint RotateCounterClockwiseId = 0x10000324u; + internal const uint ZoomInId = 0x10000325u; + internal const uint ZoomOutId = 0x10000326u; + internal const uint GradCircleId = 0x1000030Eu; + internal const uint ShadeScrollId = 0x10000321u; + internal const uint ViewportId = 0x100003BBu; + + /// + /// R4-4 (Campaign CC gate round 1 re-test 3): the framed instructions + /// box (the SAME gold corner/edge sprite family the Skills info-box + /// frame uses, 0x100002de-e3/0x100000e8/0xea — + /// live-DAT-confirmed identical children). Its own P0x17 caption + /// is the FULL static help paragraph (no gmCGAppearancePage + /// runtime composition exists for it — unlike Town/Summary's + /// SetTownString/SetHowToText, this text is purely + /// DAT-authored, confirmed by the absence of any matching function in + /// the named decomp). + /// + internal const uint HelpTextId = 0x100003ABu; + + /// The help box's own NESTED scrollbar child (live-DAT- + /// confirmed a direct child of , the SAME + /// structural id/nesting shape as + /// CharacterCreationSummaryPage.HowToScrollRelativeId's own + /// how-to box scrollbar). + private const uint HelpScrollRelativeId = 0x100002E7u; + + /// Retail's nine SetColor(0..8) swatch buttons, in + /// index order — verbatim off ListenToElementMessage's cases + /// 5-0xd (elementId - 0x1000030a). + internal static readonly uint[] SwatchIds = + [ + 0x1000030Fu, 0x10000310u, 0x10000311u, 0x10000312u, 0x10000313u, + 0x10000314u, 0x10000315u, 0x10000316u, 0x10000317u, + ]; + + /// + /// GF-9 (Campaign CC gate round 1 Batch B): the nine Type-3 companion + /// "selected" overlay elements, one per entry at + /// the SAME index — retail gmCGAppearancePage::InitializePage's + /// own id-pair table (@0x004800ff-00480164, the switch that fills + /// m_tColorWheel[i][0x10]/[0x14]-ish offsets with the + /// swatch/overlay id pair per index) resolves the SAME nine ids this + /// array carries, in the SAME index order. SetColor (case + /// 0x0047DD50, iCurColor assignment) is retail's actual + /// click-feedback mechanism — SetVisible on the overlay at + /// iCurColor's index, NOT a state swap on the swatch itself (the + /// swatch buttons author only an unnamed DirectState sprite; measured + /// against the installed dat, they have NO Normal/Highlight media at + /// all, so was always a complete no-op + /// here — see ). Live-DAT- + /// probe-confirmed siblings of the swatches under the same color-wheel + /// container (0x100003B9), each roughly centered on its paired + /// swatch's own rect. + /// + internal static readonly uint[] SwatchOverlayIds = + [ + 0x10000318u, 0x10000319u, 0x1000031Au, 0x1000031Bu, 0x1000031Cu, + 0x1000031Du, 0x1000031Eu, 0x1000031Fu, 0x10000320u, + ]; + + /// Live-DAT-measured arrow geometry, uniform across all nine + /// spins (every one is 200px wide): decrement child at local + /// x=[80,127), increment child at x=[127,174). Anything outside both + /// zones is the spin's own BODY click (retail cases 0xa5-0xa9 + /// and their headgear/shirt/trousers/footwear mirrors). + private const float DecrementZoneStart = 80f; + private const float IncrementZoneStart = 127f; + private const float IncrementZoneEnd = 174f; + + private readonly CharacterCreationRuntimeBindings _bindings; + private readonly UiButton? _femaleButton; + private readonly UiButton? _maleButton; + private readonly UiButton? _faceButton; + private readonly UiButton? _clothesButton; + private readonly UiElement? _faceChoices; + private readonly UiElement? _clothesChoices; + private readonly Dictionary _spins = []; + private readonly UiButton?[] _swatches = new UiButton?[SwatchIds.Length]; + private readonly UiElement?[] _swatchOverlays = new UiElement?[SwatchOverlayIds.Length]; + private readonly UiScrollbar? _shadeScroll; + private readonly UiButton? _rotateClockwise; + private readonly UiButton? _rotateCounterClockwise; + private readonly UiButton? _zoomIn; + private readonly UiButton? _zoomOut; + private readonly UiText? _helpText; + + /// The gradient disc (0x1000030e) — Type 3 in the + /// authored dat, so (not the base + /// ) is what resolves; + /// typed concretely (post-closeout Group 1) so + /// can set directly. + private readonly UiDatElement? _gradCircle; + + private Choice _currentChoice = Choice.Face; + private Part _currentPart = Part.Hair; + private bool _eyesArrowsDisabled; + private bool _disposed; + + /// Late-bound preview control seam — see + /// 's own doc comment for why this + /// page cannot receive the real renderer at construction time. + internal IChargenPreviewControl? PreviewControl { get; set; } + + /// + /// R2-5 late-bound seams (same pattern as + /// above) for the real color-wheel mechanism — null (the default) + /// leaves every swatch/the gradient disc showing ONLY its authored + /// static art, i.e. this page's pre-Batch-G behavior. Closeout Group 1 + /// wires a DAT-backed AcDream.Content.CharGen.ChargenAppearanceCatalog + /// (which already implements all three interfaces) into these three + /// properties via CharacterCreationUiController.AppearancePalSetSource/ + /// AppearanceClothingTableSource/AppearancePaletteColorSource, + /// mirroring how itself gets wired in from + /// outside this class. + /// + internal IChargenPalSetSource? PalSetSource { get; set; } + internal IChargenClothingTableSource? ClothingTableSource { get; set; } + internal IChargenPaletteColorSource? PaletteColorSource { get; set; } + + /// + /// R3-5/R3-6 (Campaign CC gate round 1 re-test 2) late-bound seam, same + /// pattern as the three above: null (the default) leaves every swatch/ + /// the gradient disc falling back to their ordinary FaceFileOverride/ + /// ActiveFile draw — live-DAT-confirmed, each swatch and the grad + /// circle DOES author its own DirectState sprite (the RAW, + /// un-recolored spot/gradDisk template respectively — the exact same + /// RenderSurface DIDs ChargenColorSpotComposer resolves by + /// enum), so the unwired fallback shows that authored art untinted + /// (Tint stays Vector4.One when no color data exists), not literally + /// nothing. Wired by the composition root to a + /// (same site as + /// et al) once a TextureCache exists. + /// See 's own doc for WHY a + /// fourth seam is needed beyond the three color-computation ones above: + /// those three answer "what RGB is this swatch", this one answers + /// "what actual bitmap should this element's face show" — a materially + /// different question once the answer can no + /// longer be a plain multiply-tint (see R3-5's finding). + /// + internal IChargenSwatchTextureSource? SwatchTextureSource { get; set; } + + /// The authored viewport (0x100003bb) — the composition + /// root assigns its Renderer once the graphics backend exists, + /// mirroring the paperdoll's own late viewport.Renderer = ... + /// assignment. + internal UiViewport? Viewport { get; } + + internal CharacterCreationAppearancePage( + UiElement pageRoot, + CharacterCreationRuntimeBindings bindings) + { + _bindings = bindings; + + _femaleButton = Find(pageRoot, FemaleButtonId); + if (_femaleButton is not null) + _femaleButton.OnClick = () => _bindings.SelectGender(2u); + _maleButton = Find(pageRoot, MaleButtonId); + if (_maleButton is not null) + _maleButton.OnClick = () => _bindings.SelectGender(1u); + + _faceButton = Find(pageRoot, FaceButtonId); + if (_faceButton is not null) + _faceButton.OnClick = () => SelectChoice(Choice.Face); + _clothesButton = Find(pageRoot, ClothesButtonId); + if (_clothesButton is not null) + _clothesButton.OnClick = () => SelectChoice(Choice.Clothes); + + _faceChoices = Find(pageRoot, FaceChoicesId); + _clothesChoices = Find(pageRoot, ClothesChoicesId); + + BindSpin(pageRoot, HairSpinId, Part.Hair); + BindSpin(pageRoot, EyesSpinId, Part.Eyes); + BindSpin(pageRoot, NoseSpinId, Part.Nose); + BindSpin(pageRoot, MouthSpinId, Part.Mouth); + BindSpin(pageRoot, SkinSpinId, Part.Skin); + BindSpin(pageRoot, HeadgearSpinId, Part.Headgear); + BindSpin(pageRoot, ShirtSpinId, Part.Shirt); + BindSpin(pageRoot, TrousersSpinId, Part.Trousers); + BindSpin(pageRoot, FootwearSpinId, Part.Footwear); + + for (int i = 0; i < SwatchIds.Length; i++) + { + UiButton? swatch = Find(pageRoot, SwatchIds[i]); + if (swatch is null) + continue; + int index = i; + swatch.OnClick = () => SelectColor(index); + _swatches[i] = swatch; + } + + for (int i = 0; i < SwatchOverlayIds.Length; i++) + _swatchOverlays[i] = Find(pageRoot, SwatchOverlayIds[i]); + + _shadeScroll = Find(pageRoot, ShadeScrollId); + if (_shadeScroll is not null) + _shadeScroll.ScalarChanged = SetShadeFromScalar; + + _gradCircle = Find(pageRoot, GradCircleId); + + Viewport = Find(pageRoot, ViewportId); + + _rotateClockwise = Find(pageRoot, RotateClockwiseId); + if (_rotateClockwise is not null) + _rotateClockwise.OnClick = () => PreviewControl?.RotateClockwise(); + _rotateCounterClockwise = Find(pageRoot, RotateCounterClockwiseId); + if (_rotateCounterClockwise is not null) + _rotateCounterClockwise.OnClick = () => PreviewControl?.RotateCounterClockwise(); + _zoomIn = Find(pageRoot, ZoomInId); + if (_zoomIn is not null) + _zoomIn.OnClick = () => + { + PreviewControl?.ZoomIn(); + // GF-10: gmCGAppearancePage::ZoomIn @0x0047CF00 + // (@0x0047d005/0x0047d00f) ends ZoomInButton->SetState(6) + // (Highlight), ZoomOutButton->SetState(1) (Normal) — a + // mutual-exclusive pair. Re-derived from InitializePage + // @0x0047fdd0-0048032e (m_bZoomedIn = 0 at construction, + // @0x004802c3): NO explicit initial SetState call exists + // for either button, so both start at their DAT-authored + // "Normal" default (live-DAT-probe-confirmed) until the + // first real zoom click — this port does not force an + // initial Highlight. + _zoomIn.TrySetRetailState(UiButtonStateMachine.Highlight); + _zoomOut?.TrySetRetailState(UiButtonStateMachine.Normal); + }; + _zoomOut = Find(pageRoot, ZoomOutId); + if (_zoomOut is not null) + _zoomOut.OnClick = () => + { + PreviewControl?.ZoomOut(); + // GF-10: gmCGAppearancePage::ZoomOut @0x0047D050 + // (@0x0047d140/0x0047d14a) mirrors ZoomIn — ZoomOutButton + // -> Highlight(6), ZoomInButton -> Normal(1). + _zoomOut.TrySetRetailState(UiButtonStateMachine.Highlight); + _zoomIn?.TrySetRetailState(UiButtonStateMachine.Normal); + }; + + // R4-4 (Campaign CC gate round 1 re-test 3): the help box's static + // paragraph starts mid-sentence because this port never touched + // this element at all — it built through the plain DatWidgetFactory + // import path with UiText's own chat-style default + // (PreserveEndOnLayout=true, "keep a view that is already at the + // end pinned there" — see that property's own doc: "Chat uses the + // default; top-oriented reports such as Character Information + // disable it"). This box's content overflows its own view (a full + // multi-paragraph instructions block in a 292px-tall frame), and + // UiScrollable.SetExtents's own wasAtEnd check is vacuously true + // the very first time a Scroll model transitions from its + // zero-initialized state (ContentHeight=0/ViewHeight=0/ScrollY=0 -> + // MaxScroll=0 -> AtEnd=(0>=0)=true) to real overflowing content — + // with PreserveEndOnLayout still true, that spuriously pins the + // FIRST-EVER render to the bottom, hiding the opening paragraph + // exactly as reported ("right arrows next to the article of + // clothing..." is mid-way through the third paragraph, not the + // first). This is a static instructions box, not a chat transcript + // — the SAME top-oriented-report shape PreserveEndOnLayout's own + // doc already carves out. Also wires the box's own nested authored + // scrollbar (property 0x72, live-DAT-confirmed a direct child) — + // NEVER wired by this page before — so a user can still reach the + // rest of the text if it doesn't fully fit, the SAME + // scrollbar.Model = text.Scroll linkage + // CharacterCreationSummaryPage's how-to box already uses. + _helpText = Find(pageRoot, HelpTextId); + if (_helpText is not null) + { + _helpText.PreserveEndOnLayout = false; + if (Find(_helpText, HelpScrollRelativeId) is { } helpScroll) + helpScroll.Model = _helpText.Scroll; + } + + ApplyChoiceVisibility(); + } + + internal void Refresh( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + if (_disposed) + return; + + if (_femaleButton is not null) + _femaleButton.Selected = snapshot.GenderKey == 2u; + if (_maleButton is not null) + _maleButton.Selected = snapshot.GenderKey == 1u; + + // gmCGAppearancePage::Update @ ~0x0047EB46-0x0047EE95: heritage + // 6 (Gearknight) / 0xc (Olthoi) / 0xd (OlthoiAcid) hide the Clothes + // sub-tab (and, with it, every clothing spin behind it), hide the + // Nose/Mouth spins directly, and disable the Eyes spin's arrows — + // none of these three heritages have separate clothing, nose, or + // mouth strip choices. + bool clothesHidden = IsClothesHiddenHeritage(snapshot.HeritageId); + if (_clothesButton is not null) + _clothesButton.Visible = !clothesHidden; + if (_spins.TryGetValue(Part.Nose, out UiButton? noseSpin)) + noseSpin.Visible = !clothesHidden; + if (_spins.TryGetValue(Part.Mouth, out UiButton? mouthSpin)) + mouthSpin.Visible = !clothesHidden; + _eyesArrowsDisabled = clothesHidden; + if (clothesHidden) + { + // Fix round F3: retail's Gearknight branch + // (@0x0047eac6/0x0047eacf) and Olthoi/OlthoiAcid branch + // (@0x0047ee32/0x0047ee3b) both call SetChoice(ECG_CHOICE_FACE) + // + SetSelection(ECG_PARTS_HAIR) UNCONDITIONALLY — every single + // time Update runs while the heritage hides Clothes, not only + // when the Clothes tab happened to be showing. A conditional + // gate here (checking _currentChoice == Choice.Clothes) missed + // the case where _currentPart was Nose or Mouth — both ALSO + // hidden by this same branch — while _currentChoice was still + // Face: acdream would leave the hidden Nose/Mouth part driving + // the shade control; retail always snaps back to Hair. + _currentChoice = Choice.Face; + _currentPart = Part.Hair; + } + ApplyChoiceVisibility(); + + // GF-6: heritage-flavored, index-independent — no gender needed. + RefreshSpinCaptions(snapshot.HeritageId); + + RefreshColorAndShadeControls(view, snapshot); + RebuildPreview(view, snapshot); + } + + /// + /// Campaign CC slice CC5: ports the Appearance case of + /// gmCharGenMainUI::DoRandom @ 0x004e7d70 (case 3) — + /// m_eCurType == ECG_CHOICE_CLOTHES -> + /// CharGenState::RandomizeClothing(state, 1), else + /// CharGenState::RandomizeAppearance(state, 0). Retires the + /// Appearance half of register AP-212 (the primitives are now real, + /// faithful ports — see RuntimeCharacterCreationState's own + /// Randomize section — not a uniform-pick approximation). + /// + internal void Randomize() + { + if (_disposed) + return; + if (_currentChoice == Choice.Clothes) + _bindings.RandomizeClothing?.Invoke(); + else + _bindings.RandomizeAppearance?.Invoke(); + } + + // ── Gender / Face-Clothes sub-tab ────────────────────────────────── + + private void SelectChoice(Choice choice) + { + if (_disposed) + return; + _currentChoice = choice; + // gmCGAppearancePage::ListenToElementMessage cases 0x9f/0xa0: + // Face -> SetSelection(ECG_PARTS_HAIR); Clothes -> + // SetSelection(ECG_PARTS_HEADGEAR). + _currentPart = choice == Choice.Face ? Part.Hair : Part.Headgear; + ApplyChoiceVisibility(); + RefreshColorAndShadeControlsFromLatestSnapshot(); + } + + private void ApplyChoiceVisibility() + { + if (_faceChoices is not null) + _faceChoices.Visible = _currentChoice == Choice.Face; + if (_clothesChoices is not null) + _clothesChoices.Visible = _currentChoice == Choice.Clothes; + if (_faceButton is not null) + _faceButton.Selected = _currentChoice == Choice.Face; + if (_clothesButton is not null) + _clothesButton.Selected = _currentChoice == Choice.Clothes; + } + + // ── Spins (style cycling + select-as-current-part) ───────────────── + + private void BindSpin(UiElement pageRoot, uint id, Part part) + { + UiButton? spin = Find(pageRoot, id); + if (spin is null) + return; + _spins[part] = spin; + + if (part == Part.Skin) + { + // Skin has no style index at all — retail disables its arrow + // children outright (SetAttribute_Bool(...,0xd,1) in + // InitializePage/Update's heritage branches). Every click just + // selects Skin as the current part for the color/shade controls. + spin.OnClickAt = (_, _) => SelectPart(Part.Skin); + return; + } + + spin.OnClickAt = (x, _) => + { + if (x >= DecrementZoneStart && x < IncrementZoneStart) + CycleStyle(part, -1); + else if (x >= IncrementZoneStart && x < IncrementZoneEnd) + CycleStyle(part, +1); + else + SelectPart(part); + }; + } + + private void SelectPart(Part part) + { + if (_disposed) + return; + NormalizeChoiceOnSelect(part); + _currentPart = part; + RefreshColorAndShadeControlsFromLatestSnapshot(); + } + + /// + /// Fix round F1: ports retail's spin BODY-click normalize-and-write-back + /// — gmCGAppearancePage::ListenToElementMessage cases 0xa5- + /// 0xa9 (hair/eyes/nose/mouth/skin, @0x0047f04b-0x0047f1bf) + /// and 0xab-0xae (headgear/shirt/trousers/footwear, + /// @0x0047f212-0x0047f3ac) each re-clamp the part's current index + /// into [0, count) BEFORE selecting it as current, not just read + /// it. Retail's rule (Hair's case 0xa5 is representative, + /// @0x0047f051-0x0047f081 plus the shared tail at + /// label_47f065/label_47f6d9): cur >= count -> 0; + /// cur < 0 -> count-1. Headgear's own case (0xab, + /// @0x0047f218-0x0047f23e) excludes its 0xffffffff Unset + /// sentinel from the "cur < 0" branch + /// (iCurrentChoice < 0 && iCurrentChoice != 0xffffffff), + /// so an Unset headgear survives a body click untouched; every other + /// indexed spin has no such exclusion, so an Unset (AP-214 honest-blank) + /// style wraps to count-1 on the FIRST body click — the same + /// count-1 wrap 's own decrement-from-Unset fix + /// (F1's sibling finding) applies. Skin (case 0xa9, + /// @0x0047f1bf-0x0047f1fb) normalizes its local cache too but + /// never writes back (no CharGenState field for Skin — acdream: + /// no case), matching this method's no-op + /// early return for it. In acdream there is no separate UI-local cache + /// to desync from the persisted index (unlike retail's m_tChoices) + /// — + /// already rejects any out-of-range write and + /// ConstrainAppearanceByGenderLocked already clamps on every + /// gender change — so the ONLY reachable out-of-range case here is + /// Unset itself; the >=count branch is kept for completeness/fidelity + /// with retail's own defensive shape, not because acdream can hit it. + /// + private void NormalizeChoiceOnSelect(Part part) + { + ChargenAppearanceSlot? slot = StyleSlotFor(part); + if (slot is null) + return; // Skin: retail normalizes locally but never writes back. + + IRuntimeCharacterCreationView? view = _bindings.View(); + if (view is null) + return; + RuntimeCharacterCreationSnapshot snapshot = view.Snapshot; + if (!TryGetGender(view, snapshot, out ChargenGenderOptions? gender)) + return; + + int count = StyleCount(part, gender); + if (count <= 0) + return; + uint current = StyleCurrent(part, snapshot.Appearance); + + uint normalized; + if (part == Part.Headgear) + { + // 0x0047f218/0x0047f226: cur >= count -> Unset; Unset itself + // (cur < 0 as signed int32) is explicitly excluded from the + // "cur < 0 -> count-1" branch, so it stays Unset. + if (current != Unset && current >= (uint)count) + normalized = Unset; + else + return; + } + else + { + // 0x0047f04b family: cur >= count -> 0; cur < 0 -> count-1. + // Unset (0xFFFFFFFF) reads as -1 in retail's signed int32 store, + // so it takes the "cur < 0" branch same as any other negative. + if (current != Unset && current >= (uint)count) + normalized = 0u; + else if (current == Unset) + normalized = (uint)(count - 1); + else + return; + } + + _bindings.SetAppearanceIndex?.Invoke(slot.Value, normalized); + } + + private void CycleStyle(Part part, int delta) + { + if (_disposed) + return; + if (part == Part.Eyes && _eyesArrowsDisabled) + { + SelectPart(part); + return; + } + + IRuntimeCharacterCreationView? view = _bindings.View(); + if (view is null) + return; + RuntimeCharacterCreationSnapshot snapshot = view.Snapshot; + if (!TryGetGender(view, snapshot, out ChargenGenderOptions? gender)) + return; + + ChargenAppearanceSlot? slot = StyleSlotFor(part); + if (slot is null) + { + SelectPart(part); + return; + } + + int count = StyleCount(part, gender); + uint current = StyleCurrent(part, snapshot.Appearance); + // Headgear alone allows the Unset ("no headgear") ring position — + // CharGenState::SetHeadgearStyle's decomp-derived (count+1)-position + // ring (0..count-1, Unset); every other style spin cycles [0,count). + uint next = CycleIndex(current, delta, count, allowUnset: part == Part.Headgear); + _bindings.SetAppearanceIndex?.Invoke(slot.Value, next); + SelectPart(part); + } + + /// + /// Retail's decomp-derived wrap: reproduces + /// CharGenState::SetHeadgearStyle's literal signed-int32 ring of + /// +1 positions (every real index, plus + /// — decrementing from index 0 lands on Unset, + /// incrementing from Unset lands on index 0, matching + /// ListenToElementMessage's cases 6 exactly). + /// + /// + /// Fix round F1: every OTHER style spin ALSO has a decomp- + /// observable Unset-cycling case — it lives in the same switch the + /// headgear ring was ported from, at the shared decrement tail + /// (label_47f065/label_47f6d9, reached from Hair's + /// decrement case @0x0047f465-0x0047f486 and, inlined per-part, + /// from Eyes/Nose/Mouth/Shirt/Trousers/Footwear's own decrement cases + /// @0x0047f491-0x0047f65c): decrementing FROM Unset + /// (cur=-1 as signed int32) computes new = cur - 1 = -2, + /// which is < 0, so it wraps to count - 1 — the SAME + /// "wrap to the last index" shape headgear's own ring uses, just without + /// headgear's extra Unset ring position. Incrementing FROM Unset + /// computes new = -1 + 1 = 0, which is already in + /// [0, count), so it lands on style 0 — this half was already + /// correct. The prior doc here claimed "no decomp-observable + /// Unset-cycling case" and picked index 0 for BOTH directions; the + /// decomp refutes that for decrement. This matters in practice: AP-214's + /// honest-blank open leaves every non-headgear index Unset, so the + /// FIRST left-arrow click a user makes on this page hits this exact + /// path. + /// + /// + internal static uint CycleIndex(uint current, int delta, int count, bool allowUnset) + { + if (count <= 0) + return Unset; + + if (allowUnset) + { + int cur = current == Unset ? count : (int)current; + int size = count + 1; + int next = Mod(cur + delta, size); + return next == count ? Unset : (uint)next; + } + + if (current == Unset) + { + // Retail's per-part decrement/increment cases each recompute + // `new = cur + delta` on the RAW signed int32 (Unset = -1) and + // apply a SINGLE-STEP clamp (not a full modulo): new < 0 wraps + // to count-1, new >= count wraps to 0. Since every real caller + // only ever passes delta = -1/+1 here, evaluating that one-step + // clamp directly (rather than routing Unset through the general + // Mod() below, which assumes a valid starting index) reproduces + // retail exactly for both directions. + int fromUnset = -1 + delta; + if (fromUnset < 0) + return (uint)(count - 1); + if (fromUnset >= count) + return 0u; + return (uint)fromUnset; + } + return (uint)Mod((int)current + delta, count); + } + + private static int Mod(int value, int modulus) => + ((value % modulus) + modulus) % modulus; + + // ── Color swatches + shade scroll ─────────────────────────────────── + + private void SelectColor(int index) + { + if (_disposed) + return; + IRuntimeCharacterCreationView? view = _bindings.View(); + if (view is null) + return; + RuntimeCharacterCreationSnapshot snapshot = view.Snapshot; + if (!TryGetGender(view, snapshot, out ChargenGenderOptions? gender)) + return; + ChargenAppearanceSlot? slot = ColorSlotFor(_currentPart); + if (slot is null) + return; + + // gmCGAppearancePage::ListenToElementMessage's swatch cases each + // gate on the current part's own color-list length before calling + // SetColor — a swatch beyond the list clicks through to nothing. + int count = ColorCount(_currentPart, gender); + if (index >= count) + return; + + _bindings.SetAppearanceIndex?.Invoke(slot.Value, (uint)index); + } + + private void SetShadeFromScalar(float scalar) + { + if (_disposed) + return; + ChargenShadeSlot? slot = ShadeSlotFor(_currentPart); + if (slot is null) + return; + _bindings.SetShade?.Invoke(slot.Value, scalar); + } + + private void RefreshColorAndShadeControlsFromLatestSnapshot() + { + IRuntimeCharacterCreationView? view = _bindings.View(); + if (view is not null) + RefreshColorAndShadeControls(view, view.Snapshot); + } + + private void RefreshColorAndShadeControls( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + // Fix round F2 item 2: gmCGAppearancePage::SetSelection + // @0x0047e260 resets the PREVIOUS current-part spin to state 1 + // (@0x0047e306, this->m_pCurSelection->vtable->SetState(1)) and sets + // the NEW one to state 6 (@0x0047e837, + // this->m_pCurSelection->vtable->SetState(6)) — a literal highlight + // toggle. UiButtonStateMachine.Normal/Highlight are already retail's + // own numeric ids 1/6 (see that class); IUiDatStateful.TrySetRetailState + // is the established seam for pushing a raw retail state id + // (CharacterCreationUiController.SetMasterPageState's own pattern). + foreach ((Part spinPart, UiButton spin) in _spins) + { + spin.TrySetRetailState( + spinPart == _currentPart + ? UiButtonStateMachine.Highlight + : UiButtonStateMachine.Normal); + } + + // GF-9 (Campaign CC gate round 1 Batch B): retail's ACTUAL swatch + // click feedback is the companion overlay's visibility (SetColor + // @0x0047DD50 -> m_tColorWheel[...][0x10][iCurColor*7]->SetVisible), + // not a state swap on the swatch button — measured against the + // installed dat, the nine swatches author only a DirectState sprite + // with no Normal/Highlight media at all, so a prior + // swatch.Selected assignment here was a permanent no-op (see + // SwatchOverlayIds' own doc comment). Exactly one overlay is + // visible: the one at the current part's own selected color index. + ChargenAppearanceSlot? colorSlot = ColorSlotFor(_currentPart); + uint currentColor = colorSlot is null ? Unset : ColorCurrent(_currentPart, snapshot.Appearance); + for (int i = 0; i < _swatchOverlays.Length; i++) + { + if (_swatchOverlays[i] is { } overlay) + overlay.Visible = colorSlot is not null && currentColor == (uint)i; + } + + // AP-216 (Campaign CC gate round 1 Batch C PARTIAL -> Batch G, + // R2-5, FULL): retail's DoColorSpots @0x0047d850 blits ACTUAL-color + // art for each valid swatch and BLANK art for any swatch beyond the + // current part's real color count. The "beyond count" half shipped + // at Batch C (hiding a swatch the part's color list doesn't have); + // this batch adds the "actual color" half via + // ChargenSwatchColorResolver (see this page's own class doc). + // + // displayCount diverges from the interactive colorSlot/colorCount + // pairing for exactly one family: Nose/Mouth/Skin (colorSlot == + // null, per ColorSlotFor's own doc) still get ONE representative + // swatch in retail — SetSelection's Nose/Mouth/Skin cases each hard- + // code var_1e0 = 1 (@0x0047e456/0x0047e4b7/0x0047e510) even though + // no ListenToElementMessage case ever makes that swatch clickable + // (SetColor's switch has no case for those three parts either). + bool swatchGenderResolved = TryGetGender(view, snapshot, out ChargenGenderOptions? swatchGender); + int colorCount = colorSlot is not null && swatchGenderResolved + ? ColorCount(_currentPart, swatchGender!) + : 0; + int displayCount = colorSlot is not null ? colorCount : 1; + + ChargenSwatchRgb?[] swatchColors = swatchGenderResolved + ? ComputeSwatchColors(swatchGender!, snapshot.Appearance) + : new ChargenSwatchRgb?[SwatchIds.Length]; + + for (int i = 0; i < _swatches.Length; i++) + { + if (_swatches[i] is not { } swatch) + continue; + bool visible = i < displayCount; + // R3-5 correction: retail's own pColor->SetVisible(1) + // (DoColorSpots' own loop) is UNCONDITIONAL for all 9 swatches + // — beyond displayCount shows the BLANK/blocked art (still + // visible), never hides the element. Batch C's own "beyond + // count -> hide" half is retired by this correction. + swatch.Visible = true; + // Closeout Group 1 (Tint) + R3-5 correction (ColorKeyFaceResolver): + // Tint keeps communicating "this swatch's color is X" for every + // existing reader/test; the resolver is the SEPARATE decision + // of which pre-baked bitmap actually draws (see + // UiButton.ColorKeyFaceResolver's own doc for why a multiply + // over the authored sprite is retail-wrong here). + ChargenSwatchRgb? rgb = visible ? swatchColors[i] : null; + swatch.Tint = rgb is { } c ? ToTintColor(c) : Vector4.One; + swatch.ColorKeyFaceResolver = BuildSwatchTextureResolver(visible, rgb); + } + + // AP-217 (Batch C PARTIAL -> Batch G, R2-5, FULL) + R3-6 correction: + // gmCGAppearancePage::DoGradDisk @0x0047da90 blits the "grad plug" + // icon for Eyes (DoGradDisk(this, 1), called from SetSelection + // @0x0047e85d) and a gradient graphic TINTED with the CURRENTLY + // SELECTED swatch's own color otherwise (SetColor @0x0047dd50's + // tail, DoGradDisk(this, 0) after m_iCurColor is already updated — + // @0x0047de18). Nose/Mouth/Skin always tint from swatch index 0 + // (SetSelection hard-codes eyeColor = 0 for those three cases, + // matching displayCount's own reasoning above). R3-6: SetSelection + // @0x0047e260's own Eyes/non-Eyes branches (@0x0047e859-0047e878) + // call ONLY DoGradDisk + m_pShadeScroll->SetVisible — NEITHER + // branch ever calls m_pGradCircle->SetVisible; the disc element + // itself is never hidden for Eyes, only its CONTENT (source image) + // changes. The prior "_gradCircle.Visible = !isEyes" line was a + // misreading — corrected to always-visible. + if (_gradCircle is not null) + { + bool isEyes = _currentPart == Part.Eyes; + _gradCircle.Visible = true; + // Closeout Group 1: same Tint mechanism as the swatches above — + // the gradient disc's own authored art is multiplied by the + // currently-selected swatch's color instead of an overlay child. + int gradIndex = isEyes + ? -1 + : colorSlot is null + ? 0 + : (int)ColorCurrent(_currentPart, snapshot.Appearance); + ChargenSwatchRgb? gradColor = + gradIndex >= 0 && gradIndex < swatchColors.Length ? swatchColors[gradIndex] : null; + _gradCircle.Tint = gradColor is { } gc ? ToTintColor(gc) : Vector4.One; + // R3-6: the disc's own authored media is empty (live-DAT- + // confirmed) — supply the missing base bitmap. Non-Eyes shows + // gradDisk (multiplied by Tint above, matching retail's own + // Blit_Multiply); Eyes shows the static plug icon UNTINTED + // (Tint is already Vector4.One for Eyes via gradIndex==-1 + // above, matching retail's plain Blit_Normal). + uint gradTexture = SwatchTextureSource is { } textures + ? (isEyes ? textures.GradPlugTexture : textures.GradDiskTexture) + : 0u; + _gradCircle.RuntimeImageTexture = gradTexture; + } + + ChargenShadeSlot? shadeSlot = ShadeSlotFor(_currentPart); + if (_shadeScroll is null) + return; + // Fix round F2 item 3: gmCGAppearancePage::SetSelection HIDES the + // shade scrollbar for Eyes (@0x0047e862, SetVisible(0) — Eyes has no + // shade case in SetShade at all) and shows it otherwise + // (@0x0047e878, SetVisible(1)) — retail never DISABLES it, it + // removes it from the layout entirely. + _shadeScroll.Visible = shadeSlot is not null; + if (shadeSlot is { } slot) + { + double shade = ShadeCurrent(slot, snapshot.Appearance); + float scalar = shade < 0.0 ? 0f : (float)Math.Clamp(shade, 0.0, 1.0); + _shadeScroll.SetScalarPosition(scalar); + } + } + + /// + /// R3-5: which pre-baked bitmap (if any) a swatch's own + /// should resolve to on + /// THIS refresh. A fresh closure per call (not a cached delegate) so a + /// later assignment (the composition + /// root wires it once the graphics backend exists, strictly after this + /// page's own construction — same ordering as + /// ) is picked up the next time the + /// resolver actually RUNS (at draw time), not frozen at the OLD (null) + /// value from an earlier refresh. + /// + /// Beyond displayCount ( + /// false): always resolve to the BLOCKED/blank art — retail shows this + /// for every swatch its current color count doesn't reach. + /// In range with a real color: resolve to that + /// color's own baked spot. + /// In range but no color data yet (palette seams + /// unwired): null — falls back to the ordinary FaceFileOverride/ + /// ActiveFile draw, which shows the swatch's own AUTHORED DirectState + /// sprite (the raw, un-recolored spot template — live-DAT-confirmed + /// present, see 's own doc) untinted, + /// matching this page's pre-R3-5 "fully inert until wired" + /// disposition for the OTHER three palette seams. + /// + /// + private Func? BuildSwatchTextureResolver(bool visible, ChargenSwatchRgb? rgb) + { + if (!visible) + return () => SwatchTextureSource?.BlankSpotTexture ?? 0u; + if (rgb is { } c) + return () => SwatchTextureSource?.GetActiveSpotTexture(c) ?? 0u; + return null; + } + + // ── Real swatch/gradient colors (R2-5) ────────────────────────────── + + private static readonly ChargenSwatchRgb?[] EmptySwatchColors = new ChargenSwatchRgb?[SwatchIds.Length]; + + /// + /// Computes one representative per + /// swatch slot (0..8, matching 's own order) for + /// , or an all-null array wherever the + /// palette-resolution seams (/ + /// /) + /// aren't wired yet — see this page's own class doc + + /// 's doc + /// for the retail mechanism each branch below ports. + /// + private ChargenSwatchRgb?[] ComputeSwatchColors( + ChargenGenderOptions gender, RuntimeCharacterCreationAppearance appearance) + { + if (PalSetSource is not { } palSets || PaletteColorSource is not { } colors) + return EmptySwatchColors; + + var result = new ChargenSwatchRgb?[SwatchIds.Length]; + switch (_currentPart) + { + case Part.Hair: + FillPalSetFamily(result, gender.HairColors, palSets, colors, ChargenSwatchColorResolver.HairSampleIndex); + break; + case Part.Eyes: + FillDirectFamily(result, gender.EyeColors, colors, ChargenSwatchColorResolver.EyeSampleIndex); + break; + case Part.Nose: + case Part.Mouth: + case Part.Skin: + // Retail: ONE representative swatch sourced from the + // single skin PalSet (SetSelection's Nose/Mouth/Skin cases, + // @0x0047e488/0x0047e4e9/0x0047e542 — all three set + // __return = 0xb0 against the same skinPalSetID DBObj get). + if (ChargenSwatchColorResolver.TryGetPalSetAverageColor( + palSets, colors, gender.SkinPalSetId, + ChargenSwatchColorResolver.SkinFamilySampleIndex, out ChargenSwatchRgb skin)) + { + result[0] = skin; + } + break; + case Part.Headgear: + FillClothingFamily(result, gender, gender.Headgears, appearance.HeadgearStyle, palSets, colors); + break; + case Part.Shirt: + FillClothingFamily(result, gender, gender.Shirts, appearance.ShirtStyle, palSets, colors); + break; + case Part.Trousers: + FillClothingFamily(result, gender, gender.Pants, appearance.TrousersStyle, palSets, colors); + break; + case Part.Footwear: + FillClothingFamily(result, gender, gender.Footwear, appearance.FootwearStyle, palSets, colors); + break; + } + return result; + } + + /// Hair's shape: one PalSet id per swatch index, straight off + /// (already the exact + /// list + /// indexes for the SAME selection when composing the 3D preview). + private static void FillPalSetFamily( + ChargenSwatchRgb?[] result, + IReadOnlyList palSetIds, + IChargenPalSetSource palSets, + IChargenPaletteColorSource colors, + int sampleIndex) + { + int count = Math.Min(result.Length, palSetIds.Count); + for (int i = 0; i < count; i++) + { + if (ChargenSwatchColorResolver.TryGetPalSetAverageColor( + palSets, colors, palSetIds[i], sampleIndex, out ChargenSwatchRgb c)) + { + result[i] = c; + } + } + } + + /// Eyes' shape: one Palette id per swatch index DIRECTLY off + /// — no PalSet + /// indirection, no averaging (see 's own + /// doc for why Eyes is the one exception). + private static void FillDirectFamily( + ChargenSwatchRgb?[] result, + IReadOnlyList paletteIds, + IChargenPaletteColorSource colors, + int sampleIndex) + { + int count = Math.Min(result.Length, paletteIds.Count); + for (int i = 0; i < count; i++) + { + if (ChargenSwatchColorResolver.TryGetDirectColor(colors, paletteIds[i], sampleIndex, out ChargenSwatchRgb c)) + result[i] = c; + } + } + + /// + /// Headgear/Shirt/Trousers/Footwear's shape: every swatch index shares + /// the SAME template-id + /// list (register AP-208), resolved against the CURRENTLY EQUIPPED + /// garment's own ClothingTable — see + /// 's + /// own doc for why a direct by-id lookup reproduces retail's + /// StoreColorInformation result without needing its own array- + /// building order. + /// + private void FillClothingFamily( + ChargenSwatchRgb?[] result, + ChargenGenderOptions gender, + IReadOnlyList gearOptions, + uint styleIndex, + IChargenPalSetSource palSets, + IChargenPaletteColorSource colors) + { + if (ClothingTableSource is not { } clothingTables) + return; + // Retail: an Unset ("no garment") style leaves numHeadgearColors + // (etc) at its CharGenState::SetHeadgearStyle @0x005c5350 reset + // value of 0 — no garment equipped means no dye choices to show. + if (styleIndex == Unset || styleIndex >= (uint)gearOptions.Count) + return; + + uint clothingTableId = gearOptions[(int)styleIndex].ClothingTableId; + IReadOnlyList clothingColors = gender.ClothingColors; + int count = Math.Min(result.Length, clothingColors.Count); + for (int i = 0; i < count; i++) + { + if (!ChargenSwatchColorResolver.TryGetClothingSwatchPalSetId( + clothingTables, clothingTableId, clothingColors[i], out uint palSetId)) + { + continue; + } + if (ChargenSwatchColorResolver.TryGetPalSetAverageColor( + palSets, colors, palSetId, ChargenSwatchColorResolver.ClothingSampleIndex, out ChargenSwatchRgb c)) + { + result[i] = c; + } + } + } + + private static Vector4 ToTintColor(ChargenSwatchRgb rgb) => + new(rgb.R / 255f, rgb.G / 255f, rgb.B / 255f, 1f); + + // ── Spin captions ──────────────────────────────────────────────── + + /// + /// GF-6/AP-218 (Campaign CC gate round 1 Batch C): + /// gmCGAppearancePage::Update @ 0x0047e8f0 writes the Hair/Eyes/ + /// Skin spins' caption to a heritage-flavored STATIC string via + /// UIElement_Text::SetStringInfoWithFont — never an index or a + /// style name. Normal heritage: ID_CharGen_HairStyle/ + /// _Eyes/_Skin (@0x0047ebad/0x0047ebe3/0x0047ec6a). + /// Gearknight (heritage 6): ID_CharGen_GearText_HairButton/ + /// _EyesButton/_SkinButton (@0x0047e9ef/0x0047ea25/ + /// 0x0047eaa9). Olthoi/OlthoiAcid (heritage 0xc/0xd): + /// ID_CharGen_OlthoiText_HairButton/_EyesButton/ + /// _SkinButton (@0x0047ed5b/0x0047ed91/0x0047ee15). The other + /// six spins (Nose/Mouth/Headgear/Shirt/Trousers/Footwear) are NEVER + /// touched by Update — their DAT-authored static caption + /// (already resolved at build time by + /// DatWidgetFactory.BuildButton's own P0x17 lift) is left + /// alone. Retail shows NO per-style index or name anywhere on this + /// page — the live 3D preview is the player's only feedback for which + /// style/gear is currently selected; acdream's own prior "1-based + /// ordinal"/gear-name substitution here was never a retail behavior + /// (register AP-218, retired by this fix; AP-215's own icon-thumbnail + /// item stays open — a DIFFERENT gap, see that row's own text). + /// + private void RefreshSpinCaptions(uint heritageId) + { + (string hairKey, string eyesKey, string skinKey) = heritageId switch + { + (uint)ChargenHeritageGroup.Gearknight => ( + "ID_CharGen_GearText_HairButton", + "ID_CharGen_GearText_EyesButton", + "ID_CharGen_GearText_SkinButton"), + (uint)ChargenHeritageGroup.Olthoi or (uint)ChargenHeritageGroup.OlthoiAcid => ( + "ID_CharGen_OlthoiText_HairButton", + "ID_CharGen_OlthoiText_EyesButton", + "ID_CharGen_OlthoiText_SkinButton"), + _ => ("ID_CharGen_HairStyle", "ID_CharGen_Eyes", "ID_CharGen_Skin"), + }; + + SetSpinCaption(Part.Hair, hairKey); + SetSpinCaption(Part.Eyes, eyesKey); + SetSpinCaption(Part.Skin, skinKey); + } + + private void SetSpinCaption(Part part, string key) + { + if (!_spins.TryGetValue(part, out UiButton? spin)) + return; + if (_bindings.ResolveText?.Invoke(key) is { } text) + spin.Label = text; + } + + // ── Preview rebuild ────────────────────────────────────────────── + + private void RebuildPreview( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + if (PreviewControl is null + || snapshot.HeritageId == 0u + || snapshot.GenderKey == 0u) + { + return; + } + + RuntimeCharacterCreationAppearance a = snapshot.Appearance; + var selection = new ChargenAppearanceSelection( + a.EyesStrip, a.NoseStrip, a.MouthStrip, + a.HairStyle, a.HairColor, a.EyeColor, + a.HeadgearStyle, a.HeadgearColor, + a.ShirtStyle, a.ShirtColor, + a.TrousersStyle, a.TrousersColor, + a.FootwearStyle, a.FootwearColor, + a.SkinShade, a.HairShade, a.HeadgearShade, + a.ShirtShade, a.TrousersShade, a.FootwearShade); + + PreviewControl.Rebuild(view.Options, snapshot.HeritageId, (int)snapshot.GenderKey, selection); + } + + // ── Per-part routing tables (retail SetColor @0x0047DD50 / SetShade @0x0047C860) ── + + private static ChargenAppearanceSlot? StyleSlotFor(Part part) => part switch + { + Part.Hair => ChargenAppearanceSlot.HairStyle, + Part.Eyes => ChargenAppearanceSlot.EyesStrip, + Part.Nose => ChargenAppearanceSlot.NoseStrip, + Part.Mouth => ChargenAppearanceSlot.MouthStrip, + Part.Headgear => ChargenAppearanceSlot.HeadgearStyle, + Part.Shirt => ChargenAppearanceSlot.ShirtStyle, + Part.Trousers => ChargenAppearanceSlot.TrousersStyle, + Part.Footwear => ChargenAppearanceSlot.FootwearStyle, + _ => null, // Skin. + }; + + /// Retail's SIX colorable parts (SetColor's cases + /// 0,1,5,6,7,8) — Nose/Mouth/Skin have no color list at all. + private static ChargenAppearanceSlot? ColorSlotFor(Part part) => part switch + { + Part.Hair => ChargenAppearanceSlot.HairColor, + Part.Eyes => ChargenAppearanceSlot.EyeColor, + Part.Headgear => ChargenAppearanceSlot.HeadgearColor, + Part.Shirt => ChargenAppearanceSlot.ShirtColor, + Part.Trousers => ChargenAppearanceSlot.TrousersColor, + Part.Footwear => ChargenAppearanceSlot.FootwearColor, + _ => null, + }; + + /// Retail's SetShade switch: Hair has its own shade; + /// Nose/Mouth/Skin ALL route to skin shade (cases 2/3/4 share one body + /// in the decompiled switch — a genuine retail quirk, not a porting + /// shortcut); Eyes has NO case at all (eye color has no shade + /// indirection anywhere in this campaign's model). + private static ChargenShadeSlot? ShadeSlotFor(Part part) => part switch + { + Part.Hair => ChargenShadeSlot.Hair, + Part.Nose => ChargenShadeSlot.Skin, + Part.Mouth => ChargenShadeSlot.Skin, + Part.Skin => ChargenShadeSlot.Skin, + Part.Headgear => ChargenShadeSlot.Headgear, + Part.Shirt => ChargenShadeSlot.Shirt, + Part.Trousers => ChargenShadeSlot.Trousers, + Part.Footwear => ChargenShadeSlot.Footwear, + _ => null, // Eyes. + }; + + private static int StyleCount(Part part, ChargenGenderOptions gender) => part switch + { + Part.Hair => gender.HairStyles.Count, + Part.Eyes => gender.EyeStrips.Count, + Part.Nose => gender.NoseStrips.Count, + Part.Mouth => gender.MouthStrips.Count, + Part.Headgear => gender.Headgears.Count, + Part.Shirt => gender.Shirts.Count, + Part.Trousers => gender.Pants.Count, + Part.Footwear => gender.Footwear.Count, + _ => 0, + }; + + /// Hair/Eyes have their own real per-gender color lists; + /// the four clothing slots share the gender's single + /// list (register + /// AP-208). + private static int ColorCount(Part part, ChargenGenderOptions gender) => part switch + { + Part.Hair => gender.HairColors.Count, + Part.Eyes => gender.EyeColors.Count, + Part.Headgear or Part.Shirt or Part.Trousers or Part.Footwear => + gender.ClothingColors.Count, + _ => 0, + }; + + private static uint StyleCurrent(Part part, RuntimeCharacterCreationAppearance a) => part switch + { + Part.Hair => a.HairStyle, + Part.Eyes => a.EyesStrip, + Part.Nose => a.NoseStrip, + Part.Mouth => a.MouthStrip, + Part.Headgear => a.HeadgearStyle, + Part.Shirt => a.ShirtStyle, + Part.Trousers => a.TrousersStyle, + Part.Footwear => a.FootwearStyle, + _ => Unset, + }; + + private static uint ColorCurrent(Part part, RuntimeCharacterCreationAppearance a) => part switch + { + Part.Hair => a.HairColor, + Part.Eyes => a.EyeColor, + Part.Headgear => a.HeadgearColor, + Part.Shirt => a.ShirtColor, + Part.Trousers => a.TrousersColor, + Part.Footwear => a.FootwearColor, + _ => Unset, + }; + + private static double ShadeCurrent(ChargenShadeSlot slot, RuntimeCharacterCreationAppearance a) => slot switch + { + ChargenShadeSlot.Skin => a.SkinShade, + ChargenShadeSlot.Hair => a.HairShade, + ChargenShadeSlot.Headgear => a.HeadgearShade, + ChargenShadeSlot.Shirt => a.ShirtShade, + ChargenShadeSlot.Trousers => a.TrousersShade, + ChargenShadeSlot.Footwear => a.FootwearShade, + _ => 0.0, + }; + + private static bool IsClothesHiddenHeritage(uint heritageId) => + heritageId == (uint)ChargenHeritageGroup.Gearknight + || heritageId == (uint)ChargenHeritageGroup.Olthoi + || heritageId == (uint)ChargenHeritageGroup.OlthoiAcid; + + private static bool TryGetGender( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot, + [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out ChargenGenderOptions? gender) + { + gender = null; + if (snapshot.HeritageId == 0u || snapshot.GenderKey == 0u) + return false; + if (!view.Options.TryGetHeritage(snapshot.HeritageId, out ChargenHeritageOptions? heritage)) + return false; + return heritage.GendersByKey.TryGetValue((int)snapshot.GenderKey, out gender); + } + + private static T? Find(UiElement root, uint id) where T : UiElement => + UiElement.FindDescendant(root, id) as T; + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + if (_femaleButton is not null) _femaleButton.OnClick = null; + if (_maleButton is not null) _maleButton.OnClick = null; + if (_faceButton is not null) _faceButton.OnClick = null; + if (_clothesButton is not null) _clothesButton.OnClick = null; + foreach (UiButton spin in _spins.Values) + spin.OnClickAt = null; + _spins.Clear(); + foreach (UiButton? swatch in _swatches) + { + if (swatch is not null) + swatch.OnClick = null; + } + if (_shadeScroll is not null) + _shadeScroll.ScalarChanged = null; + if (_rotateClockwise is not null) _rotateClockwise.OnClick = null; + if (_rotateCounterClockwise is not null) _rotateCounterClockwise.OnClick = null; + if (_zoomIn is not null) _zoomIn.OnClick = null; + if (_zoomOut is not null) _zoomOut.OnClick = null; + // PreviewControl is owned by the composition root (disposed with + // the leased ChargenPreviewRenderer) — just drop the reference. + PreviewControl = null; + // R2-5: same ownership shape as PreviewControl above — these are + // borrowed references into a DAT-backed catalog the composition + // root owns, not this page's own resources. + PalSetSource = null; + ClothingTableSource = null; + PaletteColorSource = null; + } +} diff --git a/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs b/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs new file mode 100644 index 00000000..a451a506 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs @@ -0,0 +1,282 @@ +using System.Numerics; +using AcDream.Core.CharGen; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.App.UI.Layout; + +/// +/// The Heritage page (gmCGHeritagePage, root 0x100003d1) — 13 +/// race buttons and the composed description text. Decomp anchors: +/// gmCGHeritagePage::InitializePage @ 0x00483a10 (button ids), +/// gmCGHeritagePage::ListenToElementMessage @ 0x00483860 (the exact +/// button-id -> heritage-id map), gmCGHeritagePage::Update @ +/// 0x00483210 (description text composition). +/// +internal sealed class CharacterCreationHeritagePage : IDisposable +{ + /// + /// Button element id -> CharGenState::SetHeritageGroup argument, + /// read verbatim off gmCGHeritagePage::ListenToElementMessage @ + /// 0x00483860's per-case literal (NOT the button element ids' + /// numeric order — e.g. 0x100005e8 maps to heritage 7/Tumerok, not to + /// its own position among the 13 ids). + /// + private static readonly IReadOnlyDictionary HeritageByButtonId = + new Dictionary + { + [0x100003BFu] = (uint)ChargenHeritageGroup.Aluvian, + [0x100003C1u] = (uint)ChargenHeritageGroup.Gharundim, + [0x100003C2u] = (uint)ChargenHeritageGroup.Sho, + // Retail gates this button (Viamontian) behind + // AccountHasThroneOfDestiny (MakeToDWarningDialog otherwise, + // @0x004838e5) — acdream has no account/DLC-ownership signal + // anywhere in ChargenOptions, so this ships without the gate + // (register AD-102, same row as the Town page's Sanamar gate). + [0x100003C3u] = (uint)ChargenHeritageGroup.Viamontian, + [0x10000590u] = (uint)ChargenHeritageGroup.Shadowbound, + [0x100005A9u] = (uint)ChargenHeritageGroup.Gearknight, + [0x100005E8u] = (uint)ChargenHeritageGroup.Tumerok, + [0x100005F1u] = (uint)ChargenHeritageGroup.Lugian, + [0x100005C4u] = (uint)ChargenHeritageGroup.Empyrean, + [0x10000591u] = (uint)ChargenHeritageGroup.Penumbraen, + [0x100005BFu] = (uint)ChargenHeritageGroup.Undead, + [0x100005C7u] = (uint)ChargenHeritageGroup.Olthoi, + [0x100005C8u] = (uint)ChargenHeritageGroup.OlthoiAcid, + }; + + /// + /// ID_CharGen_<Abbrev>Text_BonusSkills_Trained per + /// gmCGHeritagePage::Update's heritage switch (@0x004833e3): + /// Shadowbound and Penumbraen share the SAME string + /// (case 5: case 0xa:, both resolve "ShadText"). Lugian/Olthoi/ + /// OlthoiAcid have no matching string in the retail string table (the + /// decompiled switch's cases 8/0xc/0xd resolve to a vtable-slot + /// artifact instead of a string literal, and no + /// "ID_CharGen_Lug*"/"ID_CharGen_Olthoi*" key exists anywhere in the + /// named-retail dump) — those three heritages simply show the shared + /// header text with no per-heritage bonus-skills line, which is + /// retail's own real behavior here, not an acdream gap. + /// + private static readonly IReadOnlyDictionary BonusSkillsKeyByHeritage = + new Dictionary + { + [(uint)ChargenHeritageGroup.Aluvian] = "ID_CharGen_AluvianText_BonusSkills_Trained", + [(uint)ChargenHeritageGroup.Gharundim] = "ID_CharGen_GaruText_BonusSkills_Trained", + [(uint)ChargenHeritageGroup.Sho] = "ID_CharGen_ShoText_BonusSkills_Trained", + [(uint)ChargenHeritageGroup.Viamontian] = "ID_CharGen_ViaText_BonusSkills_Trained", + [(uint)ChargenHeritageGroup.Shadowbound] = "ID_CharGen_ShadText_BonusSkills_Trained", + [(uint)ChargenHeritageGroup.Penumbraen] = "ID_CharGen_ShadText_BonusSkills_Trained", + [(uint)ChargenHeritageGroup.Gearknight] = "ID_CharGen_GearText_BonusSkills_Trained", + [(uint)ChargenHeritageGroup.Tumerok] = "ID_CharGen_AunTText_BonusSkills_Trained", + [(uint)ChargenHeritageGroup.Empyrean] = "ID_CharGen_EmpText_BonusSkills_Trained", + [(uint)ChargenHeritageGroup.Undead] = "ID_CharGen_UndText_BonusSkills_Trained", + }; + + /// + /// Root 1d (Campaign CC gate round 1 Batch C): the page's own backdrop + /// element (0x100003be, live-DAT-measured 13 authored states) + /// switches per selected heritage — gmCGHeritagePage::Update + /// @0x00483210's per-case m_pBackground->SetState(...) + /// calls (heritages 5/Shadowbound and 10/Penumbraen share literals + /// 0x10000058/0x10000059 via a shared jump target, every + /// other heritage has its own distinct state). + /// + private static readonly IReadOnlyDictionary BackdropStateByHeritage = + new Dictionary + { + [(uint)ChargenHeritageGroup.Aluvian] = 0x10000021u, + [(uint)ChargenHeritageGroup.Gharundim] = 0x10000022u, + [(uint)ChargenHeritageGroup.Sho] = 0x10000023u, + [(uint)ChargenHeritageGroup.Viamontian] = 0x10000024u, + [(uint)ChargenHeritageGroup.Shadowbound] = 0x10000058u, + [(uint)ChargenHeritageGroup.Gearknight] = 0x1000005Au, + [(uint)ChargenHeritageGroup.Tumerok] = 0x1000005Fu, + [(uint)ChargenHeritageGroup.Lugian] = 0x10000060u, + [(uint)ChargenHeritageGroup.Empyrean] = 0x1000005Cu, + [(uint)ChargenHeritageGroup.Penumbraen] = 0x10000059u, + [(uint)ChargenHeritageGroup.Undead] = 0x1000005Bu, + [(uint)ChargenHeritageGroup.Olthoi] = 0x1000005Du, + [(uint)ChargenHeritageGroup.OlthoiAcid] = 0x1000005Eu, + }; + + private readonly CharacterCreationRuntimeBindings _bindings; + private readonly Action _onButtonClicked; + private readonly Dictionary _buttons = []; + private readonly UiText? _description; + private readonly UiElement? _backdrop; + private bool _disposed; + + /// Review fix round F3 (2026-08-15): + /// invoked with the RAW button element id (not the resolved heritage + /// id) on every heritage-button click, before + /// runs — mirrors retail's message bubbling from + /// gmCGHeritagePage::ListenToElementMessage up to + /// gmCharGenMainUI::ListenToElementMessage's own tab-restore + /// arm, which is keyed on the same raw id. + internal CharacterCreationHeritagePage( + UiElement pageRoot, + CharacterCreationRuntimeBindings bindings, + Action onButtonClicked) + { + _bindings = bindings; + _onButtonClicked = onButtonClicked; + foreach ((uint buttonId, uint heritageId) in HeritageByButtonId) + { + if (UiElement.FindDescendant(pageRoot, buttonId) is not UiButton button) + continue; + _buttons[button] = heritageId; + button.OnClick = () => + { + _onButtonClicked(buttonId); + Select(heritageId); + }; + } + + _description = UiElement.FindDescendant(pageRoot, 0x100003C4u) as UiText; + _backdrop = UiElement.FindDescendant(pageRoot, 0x100003BEu); + + // Commit 2/3 follow-up (Campaign CC gate round 1 Batch C): the + // description box's own linked scrollbar — live-DAT-measured + // present here (unlike Profession/Town's shorter description + // boxes, which author no scrollbar child at all) at the SAME + // relative id CharacterCreationSummaryPage's how-to box carries. + // ChatWindowController's own scrollbar.Model = transcript.Scroll + // pattern, scoped to this box's own descendant. + if (_description is not null + && UiElement.FindDescendant(_description, 0x100002E7u) is UiScrollbar descriptionScroll) + { + descriptionScroll.Model = _description.Scroll; + } + } + + internal void Refresh( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + foreach ((UiButton button, uint heritageId) in _buttons) + button.Selected = heritageId == snapshot.HeritageId; + + // Root 1d: switch the backdrop art per selected heritage. Retail + // runs this unconditionally alongside the button highlight/text + // composition below — no heritage-unset guard exists in the decomp + // beyond the dictionary lookup itself (heritageId 0 simply has no + // entry, so TryGetValue leaves the backdrop at whatever state it + // last held, matching retail's own "no case 0" switch shape). + if (_backdrop is IUiDatStateful backdropStateful + && BackdropStateByHeritage.TryGetValue(snapshot.HeritageId, out uint backdropState)) + { + backdropStateful.TrySetRetailState(backdropState); + } + + if (_description is null) + return; + + IReadOnlyList segments = ComposeSegments( + _description, view, snapshot.HeritageId, _bindings.ResolveText); + // F11 (Campaign CC gate round 1 closeout): compose ONCE here, inside + // Refresh (already revision-gated by CharacterCreationUiController.Tick + // — this method only runs when something in chargen state actually + // changed), and hand LinesProvider the already-built list instead of + // re-composing (escape-normalize + word-wrap) on EVERY draw call. + IReadOnlyList composed = DatRichText.Compose(_description, segments); + _description.LinesProvider = () => composed; + } + + internal void Randomize(RuntimeCharacterCreationSnapshot snapshot) + { + // CharGenState::RandomizeHeritageGroup has no CC3 primitive — the + // nearest faithful approximation available from this page's own + // command surface is a uniform pick over every DAT-installed + // heritage (register AP-212 alongside the Skills/Summary Random + // gaps this same finding covers). + IRuntimeCharacterCreationView? view = _bindings.View(); + if (view is null || view.Options.HeritagesById.Count == 0) + return; + uint[] ids = [.. view.Options.HeritagesById.Keys]; + uint chosen = ids[Random.Shared.Next(ids.Length)]; + Select(chosen); + } + + /// + /// Campaign CC slice CC6b-MOUNT: AD-101 RETIRED. The Appearance page's + /// real gender buttons (0x100003a7/0x100003a8) now exist, + /// so this no longer needs to auto-select a gender to keep the + /// Profession/Skills/Town pages usable — gender is a real player choice. + /// Retail's own default here is genuinely NOT blank: CharGenState:: + /// Reset @ 0x005C68A0 calls SetGender(this, 0) (unset), but + /// gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0 calls + /// CharGenState::RandomizeCharacter (0x005c6d80) BEFORE any page + /// constructs — retail's chargen screen always opens with a fully + /// RANDOM heritage/gender/appearance/clothing/template/start-area + /// already rolled (see the ~0x004e81f5-0x004e8218 ctor call, ahead of + /// every page's own InitializePage). acdream does not port + /// RandomizeCharacter this round (register AP-214, the same + /// unported-primitive gap AP-212 already tracks for the Random button) + /// — so acdream's screen opens honestly blank instead, and gender is now + /// the player's first real choice on the Appearance page. + /// + private void Select(uint heritageId) + { + if (_disposed) + return; + _bindings.SelectHeritage(heritageId); + } + + /// + /// Ports gmCGHeritagePage::Update @ 0x00483210's text + /// composition: the (heritage-independent) starting-skills header + + /// body, the bonus-skills header, then — only once a heritage is + /// selected — that heritage's own bonus-skills line (absent for + /// Lugian/Olthoi/OlthoiAcid; see ). + /// Header segments use SetStringInfoWithFont's own font-index + /// argument (1 — palette index 1, live-DAT-measured GREEN); + /// body/bonus-body segments use index 0 (white). is the DAT string lookup (RetailUiRuntime's + /// DatStringResolver over table 0x23000002) threaded + /// through the bindings record; a missing resolver or a missing key + /// degrades to skipping that segment rather than throwing. + /// + private static IReadOnlyList ComposeSegments( + UiText description, + IRuntimeCharacterCreationView view, + uint heritageId, + Func? resolveText) + { + Vector4 headerColor = DatRichText.PaletteColor(description, 1, new Vector4(0f, 1f, 0f, 1f)); + Vector4 bodyColor = DatRichText.PaletteColor(description, 0, Vector4.One); + + if (resolveText is null) + { + string name = view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? named) + ? named.Name + : string.Empty; + return [new DatRichText.Segment(name, bodyColor)]; + } + + var segments = new List(); + if (resolveText("ID_CharGen_Heritage_StartingSkills_Header") is { } header) + segments.Add(new(header, headerColor)); + if (resolveText("ID_CharGen_Heritage_StartingSkills") is { } body) + segments.Add(new(body, bodyColor)); + if (resolveText("ID_CharGen_Heritage_BonusSkills_Trained_Header") is { } bonusHeader) + segments.Add(new(bonusHeader, headerColor)); + if (heritageId != 0 + && BonusSkillsKeyByHeritage.TryGetValue(heritageId, out string? bonusKey) + && resolveText(bonusKey) is { } bonusBody) + { + segments.Add(new(bonusBody, bodyColor)); + } + return segments; + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + foreach (UiButton button in _buttons.Keys) + button.OnClick = null; + _buttons.Clear(); + } +} diff --git a/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs new file mode 100644 index 00000000..ff3433a6 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs @@ -0,0 +1,383 @@ +using System.Globalization; +using AcDream.Core.CharGen; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.App.UI.Layout; + +/// +/// The Profession page (gmCGProfessionPage, root 0x100003d2) — +/// seven template buttons and the six attribute sliders. Decomp anchors: +/// gmCGProfessionPage::InitializePage @ 0x00482d50 (slider/display +/// element ids), gmCGProfessionPage::UpdateProfession @ 0x004821b0 +/// (template-index -> button-id map, cited on ChargenTemplate), +/// gmCGProfessionPage::UpdateAttributeValues @ 0x00482450 +/// (avail/health/stamina/mana display sourcing). +/// +internal sealed class CharacterCreationProfessionPage : IDisposable +{ + /// Template button id -> template index, verbatim off + /// gmCGProfessionPage::UpdateProfession @ 0x004821b0's per-case + /// button-highlight dispatch (also the doc comment on + /// ChargenTemplate): 0 is Custom/Adventurer, and the six preset + /// buttons do NOT sit in template-index order. + private static readonly IReadOnlyDictionary TemplateByButtonId = + new Dictionary + { + [0x100003D9u] = 0u, // Custom / Adventurer + [0x100003DAu] = 1u, // Bow Hunter + [0x100003DFu] = 2u, // Swashbuckler + [0x100003DBu] = 3u, // Life Caster + [0x100003DCu] = 4u, // War Caster (aka War Mage) + [0x100003DDu] = 5u, // Wayfarer + [0x100003DEu] = 6u, // Soldier + }; + + /// + /// Attribute id -> slider container element id, verbatim off + /// gmCGProfessionPage::InitializePage @ 0x00482d50: + /// m_tSliderArray[N].pAttribField = GetChildRecursive(this, + /// id) for N=1..6 against ids 0x100003e6, e7, e9, e8, ea, eb + /// — note the e8/e9 SWAP (id e9 is slider index 3/Quickness, id e8 is + /// slider index 4/Coordination), matching + /// 's own documented 3/4 swap. + /// + private static readonly IReadOnlyDictionary SliderContainerByAttribute = + new Dictionary + { + [ChargenAttributeId.Strength] = 0x100003E6u, + [ChargenAttributeId.Endurance] = 0x100003E7u, + [ChargenAttributeId.Coordination] = 0x100003E8u, + [ChargenAttributeId.Quickness] = 0x100003E9u, + [ChargenAttributeId.Focus] = 0x100003EAu, + [ChargenAttributeId.Self] = 0x100003EBu, + }; + + // Relative (within-container) child ids, same InitializePage loop: + // 0x100002ec = lock UIElement_Button, 0x100002ed = name UIElement_Text + // (left at its authored default — see the ctor comment), + // 0x100002ee = the UIElement_Scrollbar drag control, 0x100002ef = the + // value display. Live-DAT probe (CharacterCreationLiveDatTests): + // 0x100002ef imports as a UiField, not UiText — retail's + // NumberInputFilter (attached to the sibling name field in the decomp, + // @0x00482e36) authors the whole slider row's text sub-elements as + // editable-capable; acdream's factory maps that authored shape to + // UiField. This also lets the player type an exact value directly. + private const uint SliderLockRelativeId = 0x100002ECu; + private const uint SliderControlRelativeId = 0x100002EEu; + private const uint SliderValueRelativeId = 0x100002EFu; + + private sealed record SliderWidgets(UiButton? Lock, UiScrollbar? Slider, UiField? Value); + + /// + /// GF-4b: the six slider containers' name-label CHILD, relative id + /// 0x100002edgmCGProfessionPage::InitializePage + /// @0x00482e1a-0x00482f1d writes CharGenState::GetAttributeName + /// @0x005C3A20's literal ONCE at page construction (no per-refresh + /// rewrite anywhere in the decomp — UpdateAttributeValues only + /// touches pSlider/pAttribValue, never this id). Live-DAT- + /// measured: this child resolves as Type 1 (UIElement_Button), + /// matching retail's own declared UIElement_Button* field type + /// that still accepts UIElement_Text::SetText — retail's button + /// class carries the same text-rendering capability + /// already is in this port. + /// + private const uint SliderNameRelativeId = 0x100002EDu; + + /// + /// GF-3: the description textbox — gmCGProfessionPage::InitializePage + /// @0x00483068's m_pTextBox. + /// + private const uint DescriptionTextId = 0x100003E0u; + + /// + /// Root 1d: the page's own backdrop (0x100003d8, live-DAT- + /// measured 7 authored states) switches per selected template — + /// gmCGProfessionPage::UpdateProfession @ 0x004821b0's per-case + /// eax_2->SetState(...) calls, keyed by ChargenTemplate + /// index (0=Custom..6=Soldier), NOT the button-id map above. + /// + private static readonly IReadOnlyDictionary BackdropStateByTemplate = + new Dictionary + { + [0u] = 0x1000002Bu, // Custom / Adventurer + [1u] = 0x1000002Cu, // Bow Hunter + [2u] = 0x10000031u, // Swashbuckler + [3u] = 0x1000002Du, // Life Caster + [4u] = 0x1000002Eu, // War Caster + [5u] = 0x1000002Fu, // Wayfarer + [6u] = 0x10000030u, // Soldier + }; + + /// + /// GF-3: per-template description string id — + /// gmCGProfessionPage::UpdateProfession @0x00482203-0048233d's + /// per-case var_a4_1 literal, resolved through + /// UIElement_Text::SetStringInfo (NOT ...WithFont — a single + /// plain string, no per-run palette color). + /// + private static readonly IReadOnlyDictionary DescriptionKeyByTemplate = + new Dictionary + { + [0u] = "ID_CharGen_CustomText", + [1u] = "ID_CharGen_BowText", + [2u] = "ID_CharGen_SwashText", + [3u] = "ID_CharGen_LifeText", + [4u] = "ID_CharGen_WarText", + [5u] = "ID_CharGen_WayText", + [6u] = "ID_CharGen_SoldierText", + }; + + private readonly CharacterCreationRuntimeBindings _bindings; + private readonly Dictionary _templateButtons = []; + private readonly Dictionary _sliders = []; + private readonly UiButton? _availableValue; + private readonly UiButton? _healthValue; + private readonly UiButton? _staminaValue; + private readonly UiButton? _manaValue; + private readonly UiText? _description; + private readonly UiElement? _backdrop; + private bool _disposed; + + internal CharacterCreationProfessionPage( + UiElement pageRoot, + CharacterCreationRuntimeBindings bindings) + { + _bindings = bindings; + + foreach ((uint buttonId, uint templateIndex) in TemplateByButtonId) + { + if (UiElement.FindDescendant(pageRoot, buttonId) is not UiButton button) + continue; + _templateButtons[button] = templateIndex; + button.OnClick = () => SelectTemplate(templateIndex); + } + + foreach ((ChargenAttributeId attribute, uint containerId) in SliderContainerByAttribute) + { + if (UiElement.FindDescendant(pageRoot, containerId) is not { } container) + continue; + + UiButton? lockButton = UiElement.FindDescendant(container, SliderLockRelativeId) as UiButton; + UiScrollbar? slider = UiElement.FindDescendant(container, SliderControlRelativeId) as UiScrollbar; + UiField? value = UiElement.FindDescendant(container, SliderValueRelativeId) as UiField; + + ChargenAttributeId capturedAttribute = attribute; + if (lockButton is not null) + { + lockButton.OnClick = () => ToggleLock(capturedAttribute); + } + if (slider is not null) + { + slider.Horizontal = true; + slider.ScalarChanged = scalar => SetAttributeFromScalar(capturedAttribute, scalar); + } + if (value is not null) + { + value.Editable = true; + value.CharacterFilter = char.IsAsciiDigit; + value.OnSubmit = text => SetAttributeFromText(capturedAttribute, text); + } + + // GF-4b: the name-label child is static per attribute — retail + // writes it exactly once (InitializePage), never on refresh. + if (UiElement.FindDescendant(container, SliderNameRelativeId) is UiButton nameLabel) + nameLabel.Label = AttributeName(attribute); + + _sliders[attribute] = new SliderWidgets(lockButton, slider, value); + } + + // GF-4a (Campaign CC gate round 1 Batch C): every one of the four + // display buttons (0x100003e2..e5) authors its CAPTION directly as + // its own P0x17 and carries a SEPARATE, media-less Type-12 value + // child (0x100002f1/0x100002f3 — gmCGProfessionPage::InitializePage + // @0x00482f90-0x00483062). DatWidgetFactory.BuildButton now surfaces + // that child as UiButton.ValueLabel, coexisting with the authored + // Label caption — see that method's own doc comment. Retiring the + // prior Label-clobber substitution (register AD-103). + _availableValue = UiElement.FindDescendant(pageRoot, 0x100003E2u) as UiButton; + _healthValue = UiElement.FindDescendant(pageRoot, 0x100003E3u) as UiButton; + _staminaValue = UiElement.FindDescendant(pageRoot, 0x100003E4u) as UiButton; + _manaValue = UiElement.FindDescendant(pageRoot, 0x100003E5u) as UiButton; + + _description = UiElement.FindDescendant(pageRoot, DescriptionTextId) as UiText; + _backdrop = UiElement.FindDescendant(pageRoot, 0x100003D8u); + } + + internal void Refresh( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + _ = view; + foreach ((UiButton button, uint templateIndex) in _templateButtons) + button.Selected = templateIndex == snapshot.Template; + + foreach ((ChargenAttributeId attribute, SliderWidgets widgets) in _sliders) + { + int value = GetAttribute(snapshot.Attributes, attribute); + // gmCGProfessionPage::UpdateAttributeValues @ 0x0048251d: + // SetAttribute_Float(pSlider, 0x86, value * 0.00999999978f) — + // scalar = value/100, NOT (value-AttributeMin)/(AttributeMax- + // AttributeMin). Review fix round F2 (2026-08-15): the earlier + // [10,100]<->[0,1] normalization here did not match retail. + float scalar = value / 100f; + widgets.Slider?.SetScalarPosition(scalar); + widgets.Value?.SetText(value.ToString(CultureInfo.InvariantCulture)); + if (widgets.Lock is { } lockButton) + lockButton.Selected = snapshot.IsAttributeLocked(attribute); + } + + SetDisplay(_availableValue, snapshot.RemainingAttributeCredits); + int endurance = snapshot.Attributes.Endurance; + // gmCGProfessionPage::UpdateAttributeValues @ 0x00482450: Health and + // Stamina both read CharGenState::GetAttribute(state, 2) + // (Endurance); Mana reads attribute 6 (Self). The Health call + // alone passes through an FPU divide the decompiler elided + // (_ftol2 @ 0x0048262b with no visible operand) — well-established + // AC vitals convention (Health = floor(Endurance / 2), Stamina = + // Endurance 1:1) is used here; a byte-level x87 trace would be + // needed to pin the exact MSVC rounding mode if this ever needs + // tighter verification. + SetDisplay(_healthValue, endurance / 2); + SetDisplay(_staminaValue, endurance); + SetDisplay(_manaValue, snapshot.Attributes.Self); + + // Root 1d: backdrop art per selected template. + if (_backdrop is IUiDatStateful backdropStateful + && BackdropStateByTemplate.TryGetValue(snapshot.Template, out uint backdropState)) + { + backdropStateful.TrySetRetailState(backdropState); + } + + // GF-3: description textbox — one plain segment (SetStringInfo, + // not ...WithFont), so a single DefaultColor run. + if (_description is not null + && DescriptionKeyByTemplate.TryGetValue(snapshot.Template, out string? key)) + { + string? text = _bindings.ResolveText?.Invoke(key); + var segments = new[] { new DatRichText.Segment(text, _description.DefaultColor) }; + // F11 (Campaign CC gate round 1 closeout): compose ONCE here + // (Refresh is already revision-gated) instead of re-wrapping on + // every draw call — see CharacterCreationHeritagePage.Refresh's + // own comment for the full rationale. + IReadOnlyList composed = DatRichText.Compose(_description, segments); + _description.LinesProvider = () => composed; + } + } + + internal void Randomize(RuntimeCharacterCreationSnapshot snapshot) + { + // CharGenState::RandomizeTemplate has no CC3 primitive — the + // nearest faithful approximation is a uniform pick over this + // heritage's own template list (register AP-212). + IRuntimeCharacterCreationView? view = _bindings.View(); + if (view is null + || !view.Options.TryGetHeritage(snapshot.HeritageId, out ChargenHeritageOptions? heritage) + || heritage.Templates.Count == 0) + { + return; + } + SelectTemplate((uint)Random.Shared.Next(heritage.Templates.Count)); + } + + private static int GetAttribute(ChargenAttributeValues values, ChargenAttributeId id) => id switch + { + ChargenAttributeId.Strength => values.Strength, + ChargenAttributeId.Endurance => values.Endurance, + ChargenAttributeId.Quickness => values.Quickness, + ChargenAttributeId.Coordination => values.Coordination, + ChargenAttributeId.Focus => values.Focus, + ChargenAttributeId.Self => values.Self, + _ => 0, + }; + + private static void SetDisplay(UiButton? display, int value) + { + if (display is null) + return; + // GF-4a: the button's OWN P0x17 caption ("Attribute Credits" etc.) + // stays in Label; the live number goes in the coexisting value + // slot DatWidgetFactory.BuildButton surfaced from the button's + // media-less Type-12 child. + display.ValueLabel = value.ToString(CultureInfo.InvariantCulture); + } + + /// Ports CharGenState::GetAttributeName @ 0x005C3A20 + /// verbatim — retail hardcodes these six literals directly (not a + /// DAT/localization lookup), so this port does too. + private static string AttributeName(ChargenAttributeId id) => id switch + { + ChargenAttributeId.Strength => "Strength", + ChargenAttributeId.Endurance => "Endurance", + ChargenAttributeId.Quickness => "Quickness", + ChargenAttributeId.Coordination => "Coordination", + ChargenAttributeId.Focus => "Focus", + ChargenAttributeId.Self => "Self", + _ => string.Empty, + }; + + private void SelectTemplate(uint templateIndex) + { + if (_disposed) + return; + _bindings.SelectTemplate(templateIndex); + } + + /// + /// gmCGProfessionPage::ListenToElementMessage @ 0x004829c0, the + /// scrollbar-drag case (relative id 0x100002ee, idMessage 0xa): + /// ebx = _ftol2(param*100); if (ebx < 0xa) ebx = 0xa; + /// SetAttribValue(this, parent, ebx) — truncate (not round) the + /// scalar times 100, clamp LOW only to 10, with NO upper clamp/rescale. + /// Review fix round F2 (2026-08-15): the earlier + /// AttributeMin+Round(scalar*(Max-Min)) formula here did not match + /// retail (it only happened to agree with retail at scalar=1). + /// + private void SetAttributeFromScalar(ChargenAttributeId attribute, float scalar) + { + if (_disposed) + return; + int value = Math.Max(ChargenAttributeMath.AttributeMin, (int)(scalar * 100f)); + _bindings.SetAttribute(attribute, value); + } + + /// Direct numeric entry via the value field's NumberInputFilter + /// (retail @0x00482e36) — an unparsable/empty submission is a no-op + /// rather than clamping to a guessed default. + private void SetAttributeFromText(ChargenAttributeId attribute, string text) + { + if (_disposed) + return; + if (int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out int value)) + _bindings.SetAttribute(attribute, value); + } + + private void ToggleLock(ChargenAttributeId attribute) + { + if (_disposed) + return; + RuntimeCharacterCreationSnapshot? snapshot = _bindings.View()?.Snapshot; + bool currentlyLocked = snapshot?.IsAttributeLocked(attribute) ?? false; + _bindings.SetAttributeLock(attribute, !currentlyLocked); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + foreach (UiButton button in _templateButtons.Keys) + button.OnClick = null; + _templateButtons.Clear(); + foreach (SliderWidgets widgets in _sliders.Values) + { + if (widgets.Lock is { } lockButton) + lockButton.OnClick = null; + if (widgets.Slider is { } slider) + slider.ScalarChanged = null; + if (widgets.Value is { } valueField) + valueField.OnSubmit = null; + } + _sliders.Clear(); + } +} diff --git a/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs new file mode 100644 index 00000000..40bac999 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs @@ -0,0 +1,941 @@ +using System.Globalization; +using System.Numerics; +using System.Text; +using AcDream.Core.CharGen; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.App.UI.Layout; + +/// +/// The Skills page (gmCGSkillsPage, root 0x100003d3) — now +/// ported to retail's four-bucket sorted insertion model +/// (InsertEntrySorted/UpdateSkillEntry, Specialized/Trained/ +/// UseableUntrained/UnuseableUntrained, register AP-213 CLOSED at the +/// Campaign CC gate round 1 closeout Group 2 — see the closeout paragraph +/// below). Decomp +/// anchors: gmCGSkillsPage::InitializePage @ 0x00481dd0 (listbox +/// 0x100003f7, credits meter 0x100002f3 — imports as button +/// 0x100003f9's own consumed Label, see the ctor comment — info +/// panes 0x100003fb/0x100003fc), +/// gmCGSkillsPage::UpdateCreditsMeter +/// @ 0x004808f0 (credits display is the raw +/// remainingSkillCredits — no formula). The 16 skill ids uncostable +/// in BOTH the heritage's own list and the global SkillTable (retail's own +/// skills listbox never lists them either — CC1's +/// ChargenTableReaderInstalledDatTests) are filtered out via the +/// same two-tier presence check RuntimeCharacterCreationState's +/// TryGetSkillCost uses. +/// +/// +/// GF-5 fix (Campaign CC gate round 1, Batch A, 2026-08-16): +/// RebuildRows used to require Templates[0]'s resolved root to +/// be a UiButton and treat its own Label as the row's whole content — +/// both wrong. Live-DAT-probe-confirmed against the installed EoR dat and +/// gmCGSkillsPage::DoSkillRecords @ 0x004817e0: +/// Templates[0] (0x100002F4, 3 children) is retail's own +/// bucket-HEADER row (Specialized/Trained/UseableUntrained/ +/// UnuseableUntrained — unused by this port's flat-list simplification, +/// AP-213), and the REAL skill row is Templates[1] +/// (0x100002FF, a plain container root, 7 children). Byte-traced +/// through DoSkillRecords' own GetChildRecursive calls + +/// tagSkillRecord's copy-constructor field order +/// (acclient.h struct gmCGSkillsPage::tagSkillRecord): +/// 0x10000301 = the skill NAME (set once at row build, never +/// refreshed — retail has no per-refresh name write either), +/// 0x10000302 = pSkillLevelText (the numeric skill SCORE, +/// CharGenState::GetSkillScore), 0x10000303 = +/// pUpCostText, 0x10000306 = pDownCostText, +/// 0x10000304 = pSkillUpButton (fires +/// IncreaseSkillLevel on plain click, +/// ListenToElementMessage @0x004814c0 case 0x10000304), +/// 0x10000305 = pSkillDownButton (fires +/// DecreaseSkillLevel, same dispatcher's case 0x10000305). +/// Both buttons fire on a PLAIN click, not click-vs-double-click on one +/// shared row — the row now wires exactly that, retiring AP-213's own +/// click-to-advance/double-click-retreat single-button substitution. +/// +/// +/// +/// Batch F fixes (Campaign CC gate round 1, 2026-08-16 — R2-4 + review +/// F1/F2): four of R2-4's five sub-items are fixed here; the +/// four-bucket sorted model (R2-4b) was NOT — see the closeout paragraph +/// below for where it lands. +/// +/// R2-4a (row selection): a row click (or an arrow click, matching +/// retail's own post-Increase/DecreaseSkillLevel SetSelectedItem(..., +/// 1) re-select) now selects that skill — the row's NAME text swaps to +/// (best-derived "brighter white" per the +/// user's own report + the GF-11b precedent) and the info panes +/// (0x100003fb/0x100003fc) get ShowSkillsText +/// @0x00481250's title (name + score, " (%d)\n") and bonus line +/// ("Training Bonus +5"/"Specialization Bonus +10") — see the +/// closeout paragraph below for the description/formula completion. +/// R2-4c (scrollbar): the listbox's own authored scrollbar link +/// (, dat +/// property 0x72) is now wired to +/// — the SAME +/// page-level UiScrollbar.Model linkage every other +/// UiTemplateListBox owner uses (no widget change). +/// Review F1 (cost text): SetSkillText's Untrained down-cost +/// (@0x00480877) and Specialized up-cost (@0x0048067f) are +/// literal "0", unconditional — the prior port rendered blank +/// (null) instead. The <0x3e7 (999) blank gate exists +/// ONLY on the up-cost at Untrained (@0x00480819) and Trained +/// (@0x0048071f); every down-cost write is unconditional +/// (@0x00480877/@0x00480780/@0x004806c1), including +/// Trained's raw iTrainCost even when it would exceed 999. +/// Review F2 (arrow states): SetSkillText ends every branch +/// driving pSkillUpButton/pSkillDownButton through its own +/// custom Ghosted/Enabled state pair (/ +/// — raw ids via +/// , the SAME "authored +/// custom pair" shape as GF-1's Unselected/Selected). Up is gated on +/// remainingSkillCredits vs. the advance cost and is ALWAYS ghosted +/// at Specialized (nothing above it); Down is ALWAYS ghosted at Untrained +/// (nothing below it) and otherwise gated on bUntrainable/ +/// bUnspecializable — re-derived from DoSkillRecords's own +/// tagSkillRecord build (@0x00480e40-region) as "the row's OWN +/// effective trained/specialized cost is nonzero" (a free/heritage-granted +/// skill or specialization locks its own down arrow), using the SAME +/// heritage-then-global cost this page already resolves via +/// — no new data needed. +/// +/// +/// +/// +/// Campaign CC gate round 1 closeout (Group 2, 2026-08-16) — AP-213 +/// CLOSED, R2-4b implemented: +/// threads SkillBase.MinLevel/Description/Formula from +/// the global SkillTable through +/// (Content's ChargenTableReader.Project populates it — these three +/// fields have NO per-heritage override in retail, unlike costs). Row +/// building now groups every costable skill into +/// (Specialized/Trained/UseableUntrained/UnuseableUntrained, +/// UpdateSkillEntry's own iMinlevel <= 1 useable-vs- +/// unuseable-untrained test) and sorts each bucket's rows alphabetically by +/// name (InsertEntrySorted's wcscmp compare, ported as +/// string.CompareOrdinal), inserting one Templates[0] header +/// row per bucket (caption child 0x100002f6, a +/// per the same UIElement_Button-is-DynamicCast(0xc)-compatible- +/// with-Text quirk GF-4b already used) ahead of that bucket's own +/// Templates[1] skill rows — matching DoSkillRecords's own +/// unconditional 4-header-then-populate build order exactly. Headers are +/// ALWAYS built, even for an empty bucket, matching retail (no bucket ever +/// disappears just because it has zero rows this round). Advancing/ +/// retreating a skill moves its row between buckets: +/// detects a bucket change per-row (cheap: +/// against each row's OWN cached ) rather than +/// reproducing retail's incremental InsertEntrySorted single-row +/// move — a full achieves the SAME observable +/// bucket/sort placement every tick a change is detected, with the current +/// selection explicitly preserved across that rebuild (unlike a heritage +/// change, which clears it, matching retail's own roster invalidation). +/// 's own doc covers the description/formula +/// completion. +/// +/// +internal sealed class CharacterCreationSkillsPage : IDisposable +{ + /// Retail's four skill buckets, in DoSkillRecords's own + /// build order (Specialized/Trained/UseableUntrained/UnuseableUntrained + /// — top to bottom in the listbox). + private enum SkillBucket + { + Specialized, + Trained, + UseableUntrained, + UnuseableUntrained, + } + + /// Bucket header row string-table keys, in + /// order — DoSkillRecords' + /// compute_str_hash calls (ID_CharGen_Specialized etc.). + private static readonly (SkillBucket Bucket, string StringKey)[] BucketOrder = + [ + (SkillBucket.Specialized, "ID_CharGen_Specialized"), + (SkillBucket.Trained, "ID_CharGen_Trained"), + (SkillBucket.UseableUntrained, "ID_CharGen_UseableUntrained"), + (SkillBucket.UnuseableUntrained, "ID_CharGen_UnuseableUntrained"), + ]; + + /// UpdateSkillEntry @0x00480bf0's own bucket test: + /// Specialized(3)/Trained(2) map directly; Untrained/Inactive (every + /// other value — Inactive is + /// unreachable for any row this page ever lists, since every listed + /// skill is costable and RuntimeCharacterCreationState.ResetSkillLevelsLocked + /// always seeds a costable skill's slot at Untrained-or-better, kept + /// here only for the same defensive completeness as retail's own + /// switch) split on iMinlevel <= 1. + private static SkillBucket ComputeBucket(ChargenSkillAdvancementClass level, uint minLevel) => level switch + { + ChargenSkillAdvancementClass.Specialized => SkillBucket.Specialized, + ChargenSkillAdvancementClass.Trained => SkillBucket.Trained, + _ => minLevel <= 1 ? SkillBucket.UseableUntrained : SkillBucket.UnuseableUntrained, + }; + + /// Retail's own bucket-header caption child + /// (0x100002f6, live-DAT-measured as a — + /// the same UIElement_Button-is-Text-compatible quirk GF-4b + /// already ported). + private const uint HeaderCaptionElementId = 0x100002F6u; + + /// Retail's own row-name id (set once at row build; retail + /// never re-writes it on refresh either — DoSkillRecords' + /// UIElement_Text::SetText(id_2, &var_138) at + /// 0x00481d5d runs OUTSIDE the per-refresh SetSkillText + /// call). + private const uint RowNameTextId = 0x10000301u; + + /// tagSkillRecord::pSkillLevelText — the numeric skill + /// SCORE (SetSkillText @0x00480600's + /// CharGenState::GetSkillScore call, "%d" format). + private const uint RowLevelTextId = 0x10000302u; + + /// tagSkillRecord::pUpCostText. + private const uint RowUpCostTextId = 0x10000303u; + + /// tagSkillRecord::pDownCostText. + private const uint RowDownCostTextId = 0x10000306u; + + /// tagSkillRecord::pSkillUpButton — + /// ListenToElementMessage's case 0x10000304 fires + /// IncreaseSkillLevel on a plain click (idMessage==1). + private const uint RowUpButtonId = 0x10000304u; + + /// tagSkillRecord::pSkillDownButton — same dispatcher's + /// case 0x10000305 fires DecreaseSkillLevel. + private const uint RowDownButtonId = 0x10000305u; + + /// Retail's own custom Ghosted state id for + /// pSkillUpButton/pSkillDownButton (SetSkillText's + /// own SetState(0x1000001a) calls) — distinct from the standard + /// UiButtonStateMachine.Ghosted (13) numbering; the same + /// "authored custom pair, raw retail id" shape as GF-1's + /// Unselected/Selected (0x10000016/0x10000017). + private const uint ArrowGhostedStateId = 0x1000001Au; + + /// Retail's own custom Enabled state id for the same two + /// buttons (SetState(0x1000001b)). + private const uint ArrowEnabledStateId = 0x1000001Bu; + + /// R2-4a row-selection highlight: pure white. Re-derived from + /// the GF-11b precedent (list-caption color swap Normal + /// (218,167,85) -> Highlight/white (255,255,255) on + /// selection) plus the user's own report ("retail selection turns the + /// row brighter white") absent a skills-row-specific cdb capture — the + /// direction (unselected -> brighter/whiter) is directly evidenced; + /// the exact target RGB is the best available derivation, not a live + /// measurement. + private static readonly Vector4 SelectedNameColor = Vector4.One; + + /// One built skill row: the resolved Templates[1] + /// subtree plus the child widgets needs + /// every tick, resolved once at build time rather than re-walked per + /// refresh. is the row's OWN authored + /// (DAT-default) name color, captured at build time so R2-4a's + /// selection highlight can restore it exactly on deselect. + /// is the bucket this row was LAST built into — + /// compares it against a fresh + /// call every tick to detect an + /// advance/retreat that needs a re-bucket. + private readonly record struct SkillRow( + UiElement Root, + uint SkillId, + SkillBucket Bucket, + UiText? NameText, + UiText? LevelText, + UiText? UpCostText, + UiText? DownCostText, + UiButton? UpButton, + UiButton? DownButton, + Vector4 UnselectedNameColor); + + private readonly CharacterCreationRuntimeBindings _bindings; + private readonly UiTemplateListBox? _list; + private readonly UiButton? _credits; + private readonly UiText? _infoTitle; + private readonly UiText? _infoText; + private readonly List _rows = []; + private uint _lastHeritageId; + private uint? _selectedSkillId; + private bool _rowsBuilt; + private bool _disposed; + + internal CharacterCreationSkillsPage( + UiElement pageRoot, + CharacterCreationRuntimeBindings bindings, + Func templateResolver) + { + _bindings = bindings; + _list = UiElement.FindDescendant(pageRoot, 0x100003F7u) as UiTemplateListBox; + if (_list is not null) + _list.TemplateResolver = templateResolver; + + // R2-4c (Batch F): wire the listbox's own authored scrollbar (dat + // property 0x72, UiTemplateListBox.ScrollbarElementId) the SAME + // page-level Model linkage every other UiTemplateListBox owner uses + // (ConfigOptionsPageController, SocialFriendsPageController, et + // al.) — no widget change, just resolving the id the importer + // already read and pointing its Model at this listbox's own Scroll. + if (_list is not null + && _list.ScrollbarElementId != 0 + && UiElement.FindDescendant(pageRoot, _list.ScrollbarElementId) is UiScrollbar scrollbar) + { + scrollbar.Model = _list.Scroll; + } + + // Live-DAT probe (CharacterCreationLiveDatTests): the credits meter + // (retail's m_pCreditsMeter, decomp id 0x100002f3) authors as a raw + // dat CHILD of button 0x100003f9, not as a standalone descendant of + // the page root. UiButton.ConsumesDatChildren swallows it before it + // becomes an addressable widget (the same reason UiMeter's overlay + // text needed an explicit carve-out in LayoutImporter) — the + // faithful substitute is the button's own Label, which is exactly + // the mechanism our factory already uses to surface a consumed + // Type-12 child's text (register AD-103). + // + // GF-4a (Campaign CC gate round 1 Batch C): this used to clobber + // the button's authored "Available Skill Credits" caption (retail's + // own m_pCreditsMeter, 0x100002f3, is a SEPARATE widget from the + // caption text — gmCGSkillsPage::InitializePage @0x00481e1c). + // DatWidgetFactory.BuildButton now surfaces that media-less Type-12 + // child as UiButton.ValueLabel, coexisting with Label — see + // Refresh below. + _credits = UiElement.FindDescendant(pageRoot, 0x100003F9u) as UiButton; + _infoTitle = UiElement.FindDescendant(pageRoot, 0x100003FBu) as UiText; + _infoText = UiElement.FindDescendant(pageRoot, 0x100003FCu) as UiText; + + // R3-3 (Campaign CC gate round 1 re-test 2): the title + // (0x100003fb, Y=435, Height=100) and description (0x100003fc, + // Y=460, Height=100) panes' own AUTHORED boxes overlap by 75px + // (live-DAT-measured) — retail relies on vertical JUSTIFICATION, + // not disjoint rects, to keep the two visually separate. Neither + // element authors dat property 0x15 (live-DAT-probe-confirmed + // absent on both), so both fall to whatever the unauthored default + // resolves to. Byte-traced against retail's own + // UIElement_Text::UIElement_Text ctor @0x004685ff + // (this->m_eVerticalJustification = 4) cross-referenced with + // UIElement_Text::CalcJustification @0x00467260 (the ACTUAL + // enum semantics: ecx_5==1 -> Center, ecx_5==3||5 -> the FAR edge + // (Bottom), any other value including the ctor's own default of 4 + // -> edi=0, the NEAR edge, i.e. Top): the correct unauthored + // default is TOP, not Center. This port's shared + // ElementReader/DatWidgetFactory VJustify mapping and field + // default both currently resolve an absent 0x15 to Center — a + // client-wide mismatch with real retail semantics that is NOT + // fixed here (filed as ISSUES.md #410; the blast radius spans + // every already-shipped DAT-imported UiText that relies on the + // CURRENT Center default, so a global remap needs its own + // dedicated investigation + regression sweep, not a bundled + // fix inside this page). Scoped correction: force these two + // specific panes to the value retail's ctor actually resolves + // to. Under Top justification the title (OneLine, ~1 line) sits + // near its box's own top (global Y~435) and the description + // (multi-line, honoring the SAME justification via + // ConfigureDatState's _honorDatVerticalJustification) starts near + // ITS box's own top (global Y~460) — the two boxes' TOP edges are + // 25px apart, so short/typical content no longer collides even + // though the boxes' full 100px extents still overlap on paper. + if (_infoTitle is { } infoTitle) + infoTitle.VerticalJustify = VJustify.Top; + if (_infoText is { } infoText) + infoText.VerticalJustify = VJustify.Top; + + // R4-3 (Campaign CC gate round 1 re-test 3): the description pane's + // own raw box (0x100003fc, Y=460 H=100 -> bottom Y=560, live-DAT- + // measured) extends 20px PAST the bottom of the gold decorative + // frame that visually contains BOTH info panes (0x100003fa, Y=430 + // H=110 -> bottom Y=540, the SAME GF-12 corner/edge sprite family + // Batch C un-consumed — 0x100002de-e3/0x100000e8/0xea). Retail's own + // ShowSkillsText @0x00481250 has NO code relationship between the + // text panes and this frame (SetText only; no clip/size handoff), + // and the frame's 8 children carry no dat property linking them to + // 0x100003fc either — so the frame's own geometry is the only + // authored ground truth for "the visible box," and this port's + // multi-line clip (UiText.DrawText's own PushClip(0,0,Width,Height)) + // was using the WRONG (larger, unbounded) Height, letting a long + // skill's formula line draw into blank page space below the frame's + // own border instead of being contained by it. Clamped to the + // frame's own bottom edge (never grows it — additive, defensive if a + // future dat re-extract makes the frame taller than the pane). + // Scoped exactly like the VJustify.Top correction above: this is + // NOT the client-wide "does a text pane's clip account for a + // sibling decorative frame" mechanism (no evidence any other pane in + // this codebase has the SAME independently-authored-taller-than-its- + // frame shape), so a general import-time fix is unwarranted here. + if (_infoText is { } clampedInfoText + && UiElement.FindDescendant(pageRoot, InfoBoxFrameElementId) is { } frame) + { + float frameBottom = frame.Top + frame.Height; + float paneBottom = clampedInfoText.Top + clampedInfoText.Height; + if (frameBottom < paneBottom) + clampedInfoText.Height = frameBottom - clampedInfoText.Top; + } + } + + /// + /// The gold decorative frame (Type 12, 8 sprite children — the SAME + /// GF-12 corner/edge family) that visually contains BOTH info panes + /// (0x100003fb/0x100003fc) — see the R4-3 clamp above. + /// + private const uint InfoBoxFrameElementId = 0x100003FAu; + + internal void Refresh( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + bool heritageChanged = !_rowsBuilt || _lastHeritageId != snapshot.HeritageId; + // Group 2 closeout: an advance/retreat can move a row into a + // different bucket (UpdateSkillEntry's own re-bucket-on-level- + // change) — detect that cheaply against each row's own cached + // Bucket before paying for a full rebuild. + bool bucketsChanged = !heritageChanged && AnyRowBucketChanged(view); + if (heritageChanged || bucketsChanged) + { + // Only a HERITAGE change invalidates the current selection + // (retail's own roster-replace semantics) — a bucket move keeps + // the same skill selected, just relocated within the list. + uint? preservedSkillId = heritageChanged ? null : _selectedSkillId; + RebuildRows(view, snapshot.HeritageId); + _lastHeritageId = snapshot.HeritageId; + _rowsBuilt = true; + if (preservedSkillId is { } skillId) + { + foreach (SkillRow candidate in _rows) + { + if (candidate.SkillId != skillId) + continue; + _selectedSkillId = skillId; + ApplySelectionHighlight(); + break; + } + } + } + + foreach (SkillRow row in _rows) + RefreshRowValues(row, view, snapshot); + + RefreshInfoBox(view, snapshot); + + if (_credits is { } credits) + credits.ValueLabel = snapshot.RemainingSkillCredits.ToString(CultureInfo.InvariantCulture); + } + + /// Group 2 closeout: true when any CURRENTLY BUILT row's live + /// bucket (recomputed from its skill's present level/MinLevel) no + /// longer matches the bucket it was last built into. + private bool AnyRowBucketChanged(IRuntimeCharacterCreationView view) + { + foreach (SkillRow row in _rows) + { + ChargenSkillAdvancementClass level = view.GetSkillLevel(row.SkillId); + uint minLevel = view.Options.TryGetSkillDetail(row.SkillId, out ChargenSkillDetail detail) + ? detail.MinLevel + : 1u; // Unknown detail (missing global SkillTable entry) defaults to useable — the least surprising fallback. + if (ComputeBucket(level, minLevel) != row.Bucket) + return true; + } + return false; + } + + private void RebuildRows(IRuntimeCharacterCreationView view, uint heritageId) + { + foreach (SkillRow row in _rows) + { + if (row.UpButton is not null) row.UpButton.OnClick = null; + if (row.DownButton is not null) row.DownButton.OnClick = null; + if (row.Root is UiDatElement datRoot) datRoot.OnClick = null; + } + _rows.Clear(); + _list?.Flush(); + + // The skill list is rebuilding — the caller (Refresh) decides + // whether to restore _selectedSkillId afterward (preserved across a + // bucket-move rebuild, cleared across a heritage change). + _selectedSkillId = null; + ClearInfoBox(); + + if (_list is null + || _list.Templates.Count < 2 + || _list.TemplateResolver is null + || !view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage)) + { + return; + } + + // Group 2 closeout: gather every costable skill's (id, name, bucket) + // first, group by bucket, sort each bucket alphabetically by name + // (InsertEntrySorted's own wcscmp compare), THEN build rows in + // DoSkillRecords' own header-then-rows-per-bucket order. + var byBucket = new Dictionary>(4) + { + [SkillBucket.Specialized] = [], + [SkillBucket.Trained] = [], + [SkillBucket.UseableUntrained] = [], + [SkillBucket.UnuseableUntrained] = [], + }; + for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++) + { + if (!IsCostable(heritage, view.Options, skillId)) + continue; + ChargenSkillAdvancementClass level = view.GetSkillLevel(skillId); + uint minLevel = view.Options.TryGetSkillDetail(skillId, out ChargenSkillDetail detail) + ? detail.MinLevel + : 1u; + string name = ItemAppraisalTextFormatter.SkillName((int)skillId); + byBucket[ComputeBucket(level, minLevel)].Add((skillId, name)); + } + foreach (List<(uint SkillId, string Name)> bucketSkills in byBucket.Values) + bucketSkills.Sort(static (a, b) => string.CompareOrdinal(a.Name, b.Name)); + + if (_list.Templates.Count < 1) + return; + UiTemplateListEntry headerTemplate = _list.Templates[0]; + // Templates[1] (0x100002FF) is the REAL skill row — see this + // class's own doc comment for the full byte trace. + UiTemplateListEntry rowTemplate = _list.Templates[1]; + + foreach ((SkillBucket bucket, string stringKey) in BucketOrder) + { + BuildHeaderRow(headerTemplate, stringKey); + foreach ((uint skillId, _) in byBucket[bucket]) + BuildSkillRow(rowTemplate, skillId, bucket); + } + } + + /// Builds one Templates[0] bucket-header row and writes + /// its caption () from the string + /// table — DoSkillRecords's own unconditional 4-header build, + /// regardless of whether the bucket ends up with any rows. + private void BuildHeaderRow(UiTemplateListEntry template, string stringKey) + { + if (_list!.TemplateResolver!(template.TemplateLayoutId, template.TemplateElementId) is not { } headerRoot) + return; + _list.AddPrebuiltRow(headerRoot); + if (UiElement.FindDescendant(headerRoot, HeaderCaptionElementId) is UiButton caption + && _bindings.ResolveText?.Invoke(stringKey) is { } text) + { + caption.Label = text; + } + } + + private void BuildSkillRow(UiTemplateListEntry template, uint skillId, SkillBucket bucket) + { + if (_list!.TemplateResolver!(template.TemplateLayoutId, template.TemplateElementId) is not { } rowRoot) + return; + + _list.AddPrebuiltRow(rowRoot); + + UiText? nameText = UiElement.FindDescendant(rowRoot, RowNameTextId) as UiText; + if (nameText is not null) + SetLine(nameText, ItemAppraisalTextFormatter.SkillName((int)skillId)); + // Captured AFTER SetLine (which never touches DefaultColor — + // it's read lazily inside the LinesProvider closure) so this is + // the row's own DAT-authored default color, for R2-4a's + // selection highlight to restore on deselect. + Vector4 unselectedColor = nameText?.DefaultColor ?? Vector4.One; + UiText? levelText = UiElement.FindDescendant(rowRoot, RowLevelTextId) as UiText; + UiText? upCostText = UiElement.FindDescendant(rowRoot, RowUpCostTextId) as UiText; + UiText? downCostText = UiElement.FindDescendant(rowRoot, RowDownCostTextId) as UiText; + UiButton? upButton = UiElement.FindDescendant(rowRoot, RowUpButtonId) as UiButton; + UiButton? downButton = UiElement.FindDescendant(rowRoot, RowDownButtonId) as UiButton; + + uint capturedSkillId = skillId; + // R2-4a: retail re-selects the row after an arrow click too + // (ListenToElementMessage @0x004814c0's SetSelectedItem(...,1) + // call following IncreaseSkillLevel/DecreaseSkillLevel). + if (upButton is not null) + upButton.OnClick = () => { Advance(capturedSkillId); SelectRow(capturedSkillId); }; + if (downButton is not null) + downButton.OnClick = () => { Retreat(capturedSkillId); SelectRow(capturedSkillId); }; + + // R2-4a: the row-click equivalent of retail's listbox-level + // selection notification (idElement==0x100003f7 && + // idMessage==4 in ListenToElementMessage) — UiTemplateListBox + // has no generic selection mechanism of its own (see its class + // doc), so this page opts the row in directly. Templates[1] + // (0x100002FF) resolves through DatWidgetFactory's Type-3 + // (generic-container) fallback arm to UiDatElement, which + // already carries a page-opt-in OnClick/ClickThrough seam for + // exactly this — "generic decoration; behavioral widgets opt + // back in" (UiDatElement's own doc). + if (rowRoot is UiDatElement datRow) + { + datRow.ClickThrough = false; + datRow.OnClick = () => SelectRow(capturedSkillId); + } + + _rows.Add(new SkillRow( + rowRoot, skillId, bucket, nameText, levelText, upCostText, downCostText, + upButton, downButton, unselectedColor)); + } + + private void RefreshRowValues( + SkillRow row, + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + ChargenSkillAdvancementClass level = view.GetSkillLevel(row.SkillId); + (int trainedCost, int specializedCost) = GetCosts(view, snapshot.HeritageId, row.SkillId); + uint score = _bindings.GetSkillScore?.Invoke(row.SkillId, snapshot.Attributes, level) ?? 0u; + + if (row.LevelText is { } levelText) + SetLine(levelText, score.ToString(CultureInfo.InvariantCulture)); + + // Review F1/F2 fix (Batch F): SetSkillText @0x00480600's exact + // per-state cost text + arrow-enable pair — see this class's own + // header doc for the full byte trace of every address cited below. + string upCostText; + string downCostText; + bool upEnabled; + bool downEnabled; + switch (level) + { + case ChargenSkillAdvancementClass.Specialized: + // @0x0048067f: up = literal "0", unconditional (nothing + // above Specialized). @0x004806c1: down = specCost- + // trainCost, UNCONDITIONAL (no 999-blank gate). + // @0x004806fc: up arrow ALWAYS ghosted. @0x0048070c + + // @0x004807f1/@0x004807f4: down arrow enabled iff + // bUnspecializable — re-derived as specializedCost != 0 + // (a free/heritage-granted specialization, cost 0, locks + // its own down arrow — DoSkillRecords zeroes + // bUnspecializable exactly there, @0x00480e40 region). + upCostText = "0"; + downCostText = (specializedCost - trainedCost).ToString(CultureInfo.InvariantCulture); + upEnabled = false; + downEnabled = specializedCost != 0; + break; + case ChargenSkillAdvancementClass.Trained: + // @0x0048071f: up = specCost-trainCost, blank if >=999. + // @0x00480780: down = trainCost, UNCONDITIONAL (no gate, + // even past 999). @0x004807ce: up arrow enabled iff + // remainingSkillCredits >= specCost-trainCost. + // @0x004807ec + @0x004807f1/@0x004807f4: down arrow + // enabled iff bUntrainable — re-derived as trainedCost != 0 + // (same free-skill-locks-the-down-arrow rule, mirrored on + // the trained cost). + upCostText = FormatGatedCost(specializedCost - trainedCost); + downCostText = trainedCost.ToString(CultureInfo.InvariantCulture); + upEnabled = snapshot.RemainingSkillCredits >= specializedCost - trainedCost; + downEnabled = trainedCost != 0; + break; + default: + // Untrained/Inactive. @0x00480819: up = trainCost, blank if + // >=999. @0x00480877: down = literal "0", unconditional. + // @0x004808b3: down arrow ALWAYS ghosted (nothing below + // Untrained). @0x004808d1: up arrow enabled iff + // remainingSkillCredits >= trainCost. + upCostText = FormatGatedCost(trainedCost); + downCostText = "0"; + upEnabled = snapshot.RemainingSkillCredits >= trainedCost; + downEnabled = false; + break; + } + + if (row.UpCostText is { } upCostTextWidget) + SetLine(upCostTextWidget, upCostText); + if (row.DownCostText is { } downCostTextWidget) + SetLine(downCostTextWidget, downCostText); + row.UpButton?.TrySetRetailState(upEnabled ? ArrowEnabledStateId : ArrowGhostedStateId); + row.DownButton?.TrySetRetailState(downEnabled ? ArrowEnabledStateId : ArrowGhostedStateId); + } + + /// The up-cost-only 999 blank gate (< 0x3e7, + /// data_794320 — an empty PStringBase). Never applied to a + /// down-cost or a literal "0" write — see the per-branch citations in + /// . + private static string FormatGatedCost(int cost) => + cost < 999 ? cost.ToString(CultureInfo.InvariantCulture) : string.Empty; + + private static void SetLine(UiText text, string content) => + text.LinesProvider = () => [new UiText.Line(content, text.DefaultColor)]; + + /// Same dictionary-presence gate as + /// RuntimeCharacterCreationState.TryGetSkillCost — heritage list + /// first, global SkillTable fallback. + private static bool IsCostable( + ChargenHeritageOptions heritage, + ChargenOptions options, + uint skillId) => + heritage.SkillCostsBySkillId.ContainsKey(skillId) + || options.GlobalSkillCostsBySkillId.ContainsKey(skillId); + + private static (int Trained, int Specialized) GetCosts( + IRuntimeCharacterCreationView view, + uint heritageId, + uint skillId) + { + if (view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage)) + { + if (heritage.SkillCostsBySkillId.TryGetValue(skillId, out ChargenSkillCost cost)) + return (cost.NormalCost, cost.PrimaryCost); + } + if (view.Options.GlobalSkillCostsBySkillId.TryGetValue(skillId, out ChargenSkillCost global)) + return (global.NormalCost, global.PrimaryCost); + return (0, 0); + } + + /// pSkillUpButton click: IncreaseSkillLevel + /// @0x00480ca0 — Untrained/Inactive -> Trained, + /// Trained -> Specialized. + private void Advance(uint skillId) + { + if (_disposed) + return; + ChargenSkillAdvancementClass level = _bindings.View()?.GetSkillLevel(skillId) + ?? ChargenSkillAdvancementClass.Inactive; + if (level is ChargenSkillAdvancementClass.Inactive or ChargenSkillAdvancementClass.Untrained) + _bindings.TrainSkill(skillId); + else if (level == ChargenSkillAdvancementClass.Trained) + _bindings.SpecializeSkill(skillId); + } + + /// pSkillDownButton click: DecreaseSkillLevel + /// @0x00480d60 — Specialized -> Trained, + /// Trained -> Untrained. + private void Retreat(uint skillId) + { + if (_disposed) + return; + ChargenSkillAdvancementClass level = _bindings.View()?.GetSkillLevel(skillId) + ?? ChargenSkillAdvancementClass.Inactive; + if (level == ChargenSkillAdvancementClass.Specialized) + _bindings.TrainSkill(skillId); + else if (level == ChargenSkillAdvancementClass.Trained) + _bindings.UntrainSkill(skillId); + } + + /// + /// R2-4a: row click / arrow click selection — the port's equivalent of + /// retail's listbox-level SetSelectedItem notification (see + /// 's own wiring doc). Applies the highlight + /// to every row (so the PREVIOUSLY selected row also gets restored to + /// its own ) and refreshes + /// the info panes for the newly selected skill. ' + /// View is resolved fresh here, never cached, per + /// feedback_resolve_deferred_funcs_per_call.md. + /// + private void SelectRow(uint skillId) + { + if (_disposed) + return; + _selectedSkillId = skillId; + ApplySelectionHighlight(); + if (_bindings.View() is { } view) + RefreshInfoBox(view, view.Snapshot); + } + + /// Applies / + /// to every row's name text based on — + /// factored out of so can + /// re-apply it after a bucket-move rebuild restores a preserved + /// selection onto the NEW row objects (a rebuild discards the old ones, + /// so the highlight must be re-painted, not merely remembered). + private void ApplySelectionHighlight() + { + foreach (SkillRow row in _rows) + { + if (row.NameText is { } nameText) + nameText.DefaultColor = row.SkillId == _selectedSkillId ? SelectedNameColor : row.UnselectedNameColor; + } + } + + /// + /// gmCGSkillsPage::ShowSkillsText @0x00481250 — writes + /// m_pInfoBoxTitle (0x100003fb) and m_pInfoBoxText + /// (0x100003fc) for the currently selected skill, or clears both + /// when nothing is selected (retail's own arg2==0/lookup-miss + /// arms, both UIElement_Text::ClearAllText). Title is the skill + /// name plus its current score (" (%d)\n", e.g. "Loyalty (5)"). + /// Body is: DESCRIPTION, then level-gated bonus text + /// ("Training Bonus +5"/"Specialization Bonus +10" — + /// TWO spaces before the number, matching the compiled literal + /// verbatim), then 's "Formula : ..." line — + /// eax_2[7]/eax_2[8] off the row's cached + /// tagSkillRecord, byte-traced against tagSkillRecord's + /// own field order (acclient.h). Routed through + /// (escape-normalize + word-wrap, the + /// SAME composer the description pages use) since the description text + /// can run long enough to need wrapping in this box's width; composed + /// ONCE per call (not per-frame — this method itself only runs when + /// 's caller detects a revision change) and handed + /// to as a closed-over, already-built + /// list, matching the F11 no-per-frame-recompute discipline. + /// + /// + /// Group 2 closeout (Campaign CC gate round 1): DESCRIPTION is a + /// byte-verified port (SkillBase._description, read directly off + /// the DAT) and the ONLY segment routed through + /// 's word-wrap — description text can + /// run arbitrarily long, unlike the bonus/formula lines below. The bonus + /// line ("Training Bonus +5"/"Specialization Bonus +10", + /// TWO spaces before the number, matching the compiled literal + /// verbatim) and 's result are each added as + /// their OWN single, UNWRAPPED — deliberately + /// bypassing DatRichText.Compose for these two, since its + /// word-splitting wrap () collapses + /// consecutive spaces when it rejoins tokens, which would silently + /// mangle the bonus line's own authored double-space formatting (caught + /// by SkillsPage_ArrowClick_AlsoSelectsRow_InfoBoxShowsLevelBonusLine + /// during this closeout — routing it through the wrapper the first time + /// produced "Training Bonus +5 ", single space, trailing artifact from + /// the wrapper's own newline-as-empty-paragraph handling). 's + /// prefix/per-attribute-term/divisor/bonus-suffix shape is HIGH + /// CONFIDENCE (every piece is a directly-read compiled string literal or + /// a field the DatReaderWriter binding already exposes by name); the + /// CONNECTOR text between a two-attribute formula's two terms is a + /// documented approximation (register AP-231) — see that method's own + /// doc. + /// + /// + private void RefreshInfoBox(IRuntimeCharacterCreationView view, RuntimeCharacterCreationSnapshot snapshot) + { + if (_selectedSkillId is not { } skillId) + { + ClearInfoBox(); + return; + } + + ChargenSkillAdvancementClass level = view.GetSkillLevel(skillId); + uint score = _bindings.GetSkillScore?.Invoke(skillId, snapshot.Attributes, level) ?? 0u; + string name = ItemAppraisalTextFormatter.SkillName((int)skillId); + + if (_infoTitle is { } title) + SetLine(title, $"{name} ({score.ToString(CultureInfo.InvariantCulture)})"); + + if (_infoText is { } text) + { + string bonus = level switch + { + ChargenSkillAdvancementClass.Trained => "Training Bonus +5", + ChargenSkillAdvancementClass.Specialized => "Specialization Bonus +10", + _ => string.Empty, + }; + + bool hasDetail = view.Options.TryGetSkillDetail(skillId, out ChargenSkillDetail detail); + var lines = new List(); + if (hasDetail && !string.IsNullOrEmpty(detail.Description)) + { + lines.AddRange(DatRichText.Compose( + text, [new DatRichText.Segment(detail.Description, text.DefaultColor)])); + } + if (bonus.Length > 0) + lines.Add(new UiText.Line(bonus, text.DefaultColor)); + if (hasDetail) + lines.Add(new UiText.Line(ComposeFormula(detail.Formula), text.DefaultColor)); + + text.LinesProvider = () => lines; + } + } + + /// + /// gmCGSkillsPage::MakeSkillFormula @0x00480e10 — retail's + /// formula-text composition. HIGH CONFIDENCE (directly read from + /// compiled string literals plus the field layout + /// shares with + /// the DatReaderWriter binding's own SkillFormula struct): the + /// "Formula : " prefix, the per-attribute "(%u x %s)"-vs- + /// bare-name choice (a term's own multiplier > 1 gets the + /// parenthesized multiply form, else just the attribute's name — the + /// exact eax_6 <= 1/ebx_3 <= 1 gate), the + /// " / %u" divisor suffix (gated on Divisor != 1, the + /// exact __saved_ebp_11 != 1 gate), and the " +%u" + /// additive-bonus suffix (gated on AdditiveBonus != 0, the exact + /// __saved_ebp_12 != 0 gate). + /// + /// + /// LOWER CONFIDENCE, disclosed rather than silently guessed + /// (register AP-231): the connector text between a two-attribute + /// formula's own two terms. This port renders " + " — the + /// well-known "(Attr1 + Attr2) / N" shape most published AC skill + /// formulas use — but the decompiled function's own two candidate + /// connector literals (data_7a01a4, appended between the terms; + /// data_797584, appended again immediately after BOTH terms are + /// present) could not be recovered byte-exact by this session's + /// static-only tooling (no live cdb attach, no running Ghidra MCP + /// instance): both sit behind reference-counted PStringBase + /// appends whose actual wide-character content Binary Ninja's HLIL does + /// not surface as a literal, and the surrounding control flow (a + /// goto-based re-convergence between the single-attribute and + /// dual-attribute code paths) left data_797584's exact role + /// ambiguous enough that this port does NOT invent a second connector + /// for it — a two-attribute skill's formula therefore renders as + /// "Formula : (2 x Strength) + Endurance / 4 +2"-shaped text + /// that is very likely retail-correct in STRUCTURE but not yet + /// byte-verified against a live capture. Single-attribute formulas (the + /// majority of skills) are unaffected by this gap. + /// + /// + private static string ComposeFormula(ChargenSkillFormula formula) + { + bool attribute1Active = formula.Attribute1Multiplier >= 1 && formula.Attribute1 != 0; + bool attribute2Active = formula.Attribute2Multiplier >= 1 && formula.Attribute2 != 0; + + var builder = new StringBuilder("Formula : "); + if (attribute1Active) + { + AppendAttributeTerm(builder, formula.Attribute1Multiplier, formula.Attribute1); + if (attribute2Active) + builder.Append(" + "); + } + if (attribute2Active) + AppendAttributeTerm(builder, formula.Attribute2Multiplier, formula.Attribute2); + + if (formula.Divisor != 1) + builder.Append(CultureInfo.InvariantCulture, $" / {formula.Divisor}"); + if (formula.AdditiveBonus != 0) + builder.Append(CultureInfo.InvariantCulture, $" +{formula.AdditiveBonus}"); + return builder.ToString(); + } + + private static void AppendAttributeTerm(StringBuilder builder, int multiplier, uint attributeId) + { + string name = AttributeName((ChargenAttributeId)attributeId); + if (multiplier > 1) + builder.Append(CultureInfo.InvariantCulture, $"({multiplier} x {name})"); + else + builder.Append(name); + } + + /// Ports CharGenState::GetAttributeName @ 0x005C3A20 + /// verbatim — retail hardcodes these six literals directly (not a + /// DAT/localization lookup). Duplicated locally from + /// CharacterCreationProfessionPage's own private copy rather than + /// extracted to a shared helper — six lines, two call sites, not worth + /// a new file for this closeout's scope. + private static string AttributeName(ChargenAttributeId id) => id switch + { + ChargenAttributeId.Strength => "Strength", + ChargenAttributeId.Endurance => "Endurance", + ChargenAttributeId.Quickness => "Quickness", + ChargenAttributeId.Coordination => "Coordination", + ChargenAttributeId.Focus => "Focus", + ChargenAttributeId.Self => "Self", + _ => string.Empty, + }; + + private void ClearInfoBox() + { + if (_infoTitle is { } title) SetLine(title, string.Empty); + if (_infoText is { } text) SetLine(text, string.Empty); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + foreach (SkillRow row in _rows) + { + if (row.UpButton is not null) row.UpButton.OnClick = null; + if (row.DownButton is not null) row.DownButton.OnClick = null; + if (row.Root is UiDatElement datRow) datRow.OnClick = null; + } + _rows.Clear(); + _list?.Flush(); + if (_list is not null) + _list.TemplateResolver = null; + } +} diff --git a/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs new file mode 100644 index 00000000..8662ffe7 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs @@ -0,0 +1,550 @@ +using System.Globalization; +using AcDream.App.Rendering; +using AcDream.Core.CharGen; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.App.UI.Layout; + +/// +/// The Summary page (gmCGSummaryPage, root 0x100003d6) — +/// Campaign CC slice CC5, retiring the TS-82 content-inert placeholder. +/// Decomp anchors: gmCGSummaryPage::InitializePage @ 0x0047bbf0 +/// (widget ids, its OWN gmCG3DView instance, camera set + 180° +/// heading + StartAnimation — a live idle-animated preview, not a +/// static frozen frame), ::SetSummaryText @ 0x0047b1d0 (the listbox's +/// three-template row content: template 0 = a single UiText line, +/// template 1 = a category-header UiText, template 2 = a two-column +/// key/value UiText pair — live-DAT-probe-confirmed against the +/// installed EoR dat, resolving DID 0x2100004C elements +/// 0x100002F8/FA/FB), ::ListenToElementMessage @ 0x0047bf40 +/// (the name field's commit-on-idMessage-0x12-or-0x44 dispatch, the +/// >32-char ID_CharGen_NameTooLong reject-and-revert path — see +/// 's own doc comment for the 32-vs-33 +/// reconciliation), ::DoNameLimitDialog @ 0x0047bd80. +/// +/// +/// Listbox content scope cut (register-worthy, AP-213's own precedent): +/// retail's skills section walks FOUR buckets (Specialized/Trained/ +/// UseableUntrained/UnuseableUntrained) and lists every skill name in each. +/// This port lists Specialized and Trained only — the two buckets a player +/// actually spent credits on and would review before Finishing — and skips +/// the two Untrained buckets (which would otherwise list the ~50 skills the +/// player did NOT touch, adding volume without decision-relevant +/// information). Health/Stamina/Mana reuse +/// 's own already-cited +/// UpdateAttributeValues @ 0x00482450 formulas (Health=Endurance/2, +/// Stamina=Endurance, Mana=Self) rather than this page's OWN +/// SetSummaryText call site, whose two GetAttribute calls for +/// Health/Stamina are decompiler-ambiguous (both show a literal attribute +/// index of 2 — ProfessionPage's site is the cleaner citation). +/// +/// +internal sealed class CharacterCreationSummaryPage : IDisposable +{ + internal const uint ListBoxId = 0x10000400u; + internal const uint ScrollId = 0x10000401u; + internal const uint NameTextId = 0x10000402u; + internal const uint HowToTextId = 0x10000404u; + internal const uint ViewportId = 0x10000406u; + + /// Row-template child ids, live-DAT-probe-confirmed: + /// template 0's single line, template 1's header line, template 2's + /// key/value pair. + private const uint SingleLineTextId = 0x100002F9u; + private const uint HeaderTextId = 0x100000FEu; + private const uint KeyTextId = 0x100002FCu; + private const uint ValueTextId = 0x100002FDu; + + /// + /// Retail's name[33] buffer (32 usable chars + null terminator — + /// RuntimeCharacterCreationState.TrySetName's own + /// already-established storage cap). Review fix round F6 (2026-08-16): + /// the decompiled UI-side check at ListenToElementMessage @ + /// 0x0047bf40 (~0x0047bfd1) compares the field text's + /// m_charbuffer LENGTH FIELD against the literal 0x21 + /// (33) — that field is confirmed NUL-INCLUSIVE (the SAME method's own + /// empty-field check earlier at 0x0047bf93 compares that field to + /// 1, i.e. an empty string's length reads as 1, not 0). So + /// length > 33 is EXACTLY visibleChars > 32: a + /// 32-character name has length 33 (not > 33, accepted), a + /// 33-character name has length 34 (> 33, rejected). This + /// constant was ALWAYS byte-correct, not merely internally consistent + /// with the storage cap it was originally justified against — the + /// earlier "not fully certain" hedge and its AP-225 register row are + /// both retired. + /// + private const int MaxNameLength = 32; + + /// + /// Commit 3 (Campaign CC gate round 1 Batch C): the how-to box's own + /// linked scrollbar, relative id — live-DAT-measured as the SAME + /// template child id the Heritage description box also carries + /// (0x100002e7), distinct from (the + /// listbox's own scrollbar). Profession/Town's description boxes do + /// NOT author this child at all (shorter authored text, live-DAT- + /// confirmed) — only Heritage and Summary's how-to box do. + /// + private const uint HowToScrollRelativeId = 0x100002E7u; + + /// + /// Heritage id -> (male name-list key, female name-list key) per + /// gmCGSummaryPage::SetHowToText @0x0047ae20's switch + /// (@0x0047aeb2-0x0047afda). ONLY heritages 1-4 (Aluvian/Gharundim/ + /// Sho/Viamontian) resolve to real string literals + /// ("ID_CharGen_<Abbrev>{Male,Female}Names", confirmed + /// present in the compiled string-constant table); every other + /// heritage's case in that same switch (5/0xa Shadowbound+Penumbraen + /// share one body, 6 Gearknight, 7 Tumerok, 8 Lugian, 9 Empyrean, 0xb + /// Undead, 0xc/0xd Olthoi/OlthoiAcid) decompiles to a vtable-slot + /// artifact instead of a string constant — the same decompiler- + /// mangled-symbol class the Heritage page's own + /// BonusSkillsKeyByHeritage table already documents — meaning + /// no real name-suggestion string exists for those heritages; this + /// port does not invent one. + /// + private static readonly IReadOnlyDictionary NameSuggestionKeysByHeritage = + new Dictionary + { + [(uint)ChargenHeritageGroup.Aluvian] = ("ID_CharGen_AluMaleNames", "ID_CharGen_AluFemaleNames"), + [(uint)ChargenHeritageGroup.Gharundim] = ("ID_CharGen_GharuMaleNames", "ID_CharGen_GharuFemaleNames"), + [(uint)ChargenHeritageGroup.Sho] = ("ID_CharGen_ShoMaleNames", "ID_CharGen_ShoFemaleNames"), + [(uint)ChargenHeritageGroup.Viamontian] = ("ID_CharGen_ViaMaleNames", "ID_CharGen_ViaFemaleNames"), + }; + + private readonly CharacterCreationRuntimeBindings _bindings; + private readonly RetailDialogFactory _dialogs; + private readonly string _nameTooLongMessage; + private readonly UiTemplateListBox? _list; + private readonly UiField? _nameField; + private readonly UiText? _howToText; + private string _lastCommittedName = string.Empty; + private uint _nameTooLongDialogContext; + private bool _disposed; + + /// Late-bound preview control seam — see + /// 's own doc comment for why this + /// page cannot receive the real renderer at construction time. + internal IChargenPreviewControl? PreviewControl { get; set; } + + /// The authored viewport (0x10000406) — Summary's OWN + /// gmCG3DView instance, distinct from the Appearance page's. + internal UiViewport? Viewport { get; } + + internal CharacterCreationSummaryPage( + UiElement pageRoot, + CharacterCreationRuntimeBindings bindings, + RetailDialogFactory dialogs, + string nameTooLongMessage, + Func templateResolver) + { + _bindings = bindings; + _dialogs = dialogs; + _nameTooLongMessage = nameTooLongMessage; + + _list = UiElement.FindDescendant(pageRoot, ListBoxId) as UiTemplateListBox; + // Review fix round F3 residual (found while adding its own test, + // 2026-08-16): this assignment was MISSING outright — every sibling + // page that owns a UiTemplateListBox (CharacterCreationSkillsPage, + // CharacterManagementUiController, every Options-panel controller) + // wires TemplateResolver in its own constructor; this page never + // did. Without it, ResolveTemplateRow's own `_list.TemplateResolver + // is null` guard made EVERY RebuildListbox call a silent no-op — + // the Summary listbox has never rendered a single row (Profession/ + // Gender/Heritage/Town, Attributes, Health/Stamina/Mana/Skill + // Credits, or the skill buckets) since CC5 shipped, independent of + // and masking the F3 template/score fix above. + if (_list is not null) + _list.TemplateResolver = templateResolver; + + // R2-7a (Campaign CC gate round 1 Batch E): the listbox's own linked + // scrollbar (dat property 0x72, ScrollId 0x10000401 — a SIBLING + // element, not a descendant of the listbox itself) was never wired + // to UiTemplateListBox.Scroll. Every other UiTemplateListBox owner in + // this codebase (SocialFriendsPageController, ConfigOptionsPageController, + // the Fellowship/Allegiance/Squelch pages) resolves + // ScrollbarElementId against the page root the SAME way — this page + // was the one holdout that never did. + if (_list is not null) + { + uint scrollbarElementId = _list.ScrollbarElementId; + if (scrollbarElementId != 0 + && UiElement.FindDescendant(pageRoot, scrollbarElementId) is UiScrollbar overviewScroll) + { + overviewScroll.Model = _list.Scroll; + } + } + + _nameField = UiElement.FindDescendant(pageRoot, NameTextId) as UiField; + if (_nameField is not null) + { + // NameInputFilter @ 0x004663b0: ASCII letters, space, apostrophe, + // hyphen — everything else is rejected per keystroke. + _nameField.CharacterFilter = NameInputFilter; + // Deliberately NOT capping UiField.MaxCharacters at MaxNameLength: + // retail's own >32-char check (ListenToElementMessage's own + // GetText().m_charbuffer length compare) only fires at COMMIT + // time (idMessage 0x12/0x44), which means the textbox itself + // accepts MORE than 32 characters while typing — the + // DoNameLimitDialog reject-and-revert path exists specifically + // to catch that post-typing case. A per-keystroke cap here would + // make that whole retail code path structurally unreachable. + // ListenToElementMessage @ 0x0047bf50: the name field commits on + // idMessage 0x12 OR 0x44 — acdream's UiField exposes those two + // triggers as OnFocusLost (clicking/tabbing away) and OnSubmit + // (Enter). Both route through the same commit path. + _nameField.OnFocusLost = CommitNameFromField; + _nameField.OnSubmit = CommitNameFromField; + _nameField.ClearOnSubmit = false; + _nameField.RecordHistory = false; + } + + Viewport = UiElement.FindDescendant(pageRoot, ViewportId) as UiViewport; + + // Commit 2/3 follow-up: the how-to box's linked scrollbar (Commit + // 2 made it BUILD as a real UiScrollbar; this wires it to actual + // scrolling) — ChatWindowController's own scrollbar.Model = + // transcript.Scroll pattern, scoped to THIS box's own descendant + // (the relative id recurs on Heritage's description box too, so a + // flat screen-wide lookup would be ambiguous). + _howToText = UiElement.FindDescendant(pageRoot, HowToTextId) as UiText; + if (_howToText is not null + && UiElement.FindDescendant(_howToText, HowToScrollRelativeId) is UiScrollbar howToScroll) + { + howToScroll.Model = _howToText.Scroll; + } + } + + internal void Refresh( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + if (_disposed) + return; + + // Keep the field's displayed text in sync with the committed name + // unless the player is actively typing (a mid-edit Refresh — driven + // by an unrelated selection change elsewhere on the screen — must + // not clobber their in-progress keystrokes). Review fix round F1 + // (2026-08-16): this used to arm a "_suppressNextFieldEvent" latch + // before calling SetText, on the assumption that SetText raises the + // same commit event a real keystroke/blur would. It does not — + // UiField.SetText (UiField.cs:240-248) only mutates _text/_caret and + // never invokes OnFocusLost/OnSubmit (those fire exclusively from + // OnEvent's own idMessage dispatch, UiField.cs:~313-316/:729). The + // latch therefore never had anything genuine to suppress; it just + // sat armed until the PLAYER's own next real commit, which then hit + // this early-return and silently dropped their typed name. Deleting + // the latch outright (nothing to reproduce) fixes that bug. + if (_nameField is { IsFocused: false } field && field.Text != snapshot.Name) + { + field.SetText(snapshot.Name); + _lastCommittedName = snapshot.Name; + } + + RebuildListbox(view, snapshot); + RebuildPreview(view, snapshot); + RefreshHowToText(snapshot); + } + + /// + /// Commit 3 (Campaign CC gate round 1 Batch C): ports + /// gmCGSummaryPage::SetHowToText @0x0047ae20. Retail + /// concatenates ID_CharGen_SummaryHowTo + + /// (heritage/gender-specific name-suggestion list, heritages 1-4 + /// only) + ID_CharGen_SummaryHowToEnd directly + /// (append_n_chars, no separator literal) into ONE plain + /// UIElement_Text::SetText — no per-run font/color argument, + /// unlike Heritage's ...WithFont calls, so this is a single + /// segment. + /// + private void RefreshHowToText(RuntimeCharacterCreationSnapshot snapshot) + { + if (_howToText is null) + return; + + Func? resolveText = _bindings.ResolveText; + if (resolveText is null) + return; + + var builder = new System.Text.StringBuilder(); + if (resolveText("ID_CharGen_SummaryHowTo") is { } howTo) + builder.Append(howTo); + if (NameSuggestionKeysByHeritage.TryGetValue(snapshot.HeritageId, out (string Male, string Female) keys)) + { + // gmCGSummaryPage::SetHowToText @0x0047af3f et al.: the raw + // "!= 2" comparison, no gender-unset special case — an unset + // gender (0) takes the male-key branch, matching retail's own + // literal comparison. + string key = snapshot.GenderKey == 2u ? keys.Female : keys.Male; + if (resolveText(key) is { } nameTokens) + builder.Append(nameTokens); + } + if (resolveText("ID_CharGen_SummaryHowToEnd") is { } howToEnd) + builder.Append(howToEnd); + + if (builder.Length == 0) + return; + + string composedText = builder.ToString(); + var segments = new[] { new DatRichText.Segment(composedText, _howToText.DefaultColor) }; + // F11 (Campaign CC gate round 1 closeout): compose ONCE here + // (Refresh is already revision-gated) instead of re-wrapping on + // every draw call — see CharacterCreationHeritagePage.Refresh's own + // comment for the full rationale. + IReadOnlyList composedLines = DatRichText.Compose(_howToText, segments); + _howToText.LinesProvider = () => composedLines; + } + + // ── Name field (ListenToElementMessage @ 0x0047bf40) ──────────────── + + /// + /// Review fix round F9 (2026-08-16), empty-name commit: retail's + /// ListenToElementMessage @ ~0x0047bf93 reads a length field that + /// is NUL-INCLUSIVE (confirmed at F6/F2's own byte-decode — an empty + /// field's length is 1, not 0) and gates the ENTIRE commit block — + /// including SetName — behind if (length != 1). Blurring + /// an EMPTIED field in retail therefore leaves CharGenState.name + /// UNCHANGED (whatever it held before), not cleared; DoFinish + /// later reads that unchanged internal name, so retail's field and its + /// internal state can legitimately show different things after an + /// empty-field blur. This port deliberately does NOT reproduce that: + /// it calls + /// (line below) for every commit including an empty one, so the state + /// always agrees with what the field just showed. Verified this is a + /// genuine, not cosmetic, choice — porting the exact skip would fight + /// 's own field-sync block above (the F1 fix): the + /// NEXT time anything else bumps the Runtime revision (e.g. the player + /// returns to Attributes and changes a slider, then comes back), Refresh + /// would see field.Text ("") != snapshot.Name (the stale unchanged + /// name) and forcibly restore the OLD name into the field — a + /// spontaneous, unexplained repopulation of a field the player + /// deliberately emptied, which retail's own non-continuously-refreshed + /// UI never produces. Register AP-227 records this as a deliberate + /// divergence. + /// + private void CommitNameFromField(string text) + { + if (_disposed) + return; + + if (text.Length > MaxNameLength) + { + // DoNameLimitDialog @ 0x0047bd80 (ID_CharGen_NameTooLong): + // revert the field to the last COMMITTED name rather than the + // rejected input. + _nameField?.SetText(_lastCommittedName); + ShowNameTooLongDialog(); + return; + } + + _lastCommittedName = text; + _bindings.SetName?.Invoke(text); + } + + private void ShowNameTooLongDialog() + { + // DoNameLimitDialog's own guard: a context already open is a no-op. + if (_nameTooLongDialogContext != 0u) + return; + _nameTooLongDialogContext = _dialogs.MakeMessage( + _nameTooLongMessage, + data => + { + _ = data; + _nameTooLongDialogContext = 0u; + }); + } + + /// Ports NameInputFilter @ 0x004663b0 exactly: ASCII + /// letters (isalpha), space (0x20), apostrophe + /// (0x27), or hyphen (0x2d). + private static bool NameInputFilter(char c) => + (c < 0x100 && char.IsAsciiLetter(c)) || c is ' ' or '\'' or '-'; + + // ── Listbox (SetSummaryText @ 0x0047b1d0) ─────────────────────────── + + private void RebuildListbox( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + if (_list is null || _list.Templates.Count < 3) + return; + + _list.Flush(); + + if (!view.Options.TryGetHeritage(snapshot.HeritageId, out ChargenHeritageOptions? heritage)) + return; + + UiTemplateListEntry lineTemplate = _list.Templates[0]; + UiTemplateListEntry headerTemplate = _list.Templates[1]; + UiTemplateListEntry pairTemplate = _list.Templates[2]; + + AddLine(lineTemplate, "Profession: " + ProfessionName(heritage, snapshot.Template)); + AddLine(lineTemplate, "Gender: " + GenderName(heritage, snapshot.GenderKey)); + AddLine(lineTemplate, "Heritage: " + heritage.Name); + AddLine(lineTemplate, "Starting Town: " + StarterAreaName(view.Options, snapshot.StartArea)); + + AddHeader(headerTemplate, "Attributes"); + ChargenAttributeValues a = snapshot.Attributes; + AddPair(pairTemplate, "Strength", a.Strength); + AddPair(pairTemplate, "Endurance", a.Endurance); + AddPair(pairTemplate, "Coordination", a.Coordination); + AddPair(pairTemplate, "Quickness", a.Quickness); + AddPair(pairTemplate, "Focus", a.Focus); + AddPair(pairTemplate, "Self", a.Self); + // CharacterCreationProfessionPage::Refresh's own already-cited + // UpdateAttributeValues formulas (Health=Endurance/2, Stamina= + // Endurance, Mana=Self) — see this class's own doc comment on why + // that citation is used here instead of this page's own + // decompiler-ambiguous GetAttribute(2)/GetAttribute(2) pair. + AddPair(pairTemplate, "Health", a.Endurance / 2); + AddPair(pairTemplate, "Stamina", a.Endurance); + AddPair(pairTemplate, "Mana", a.Self); + AddPair(pairTemplate, "Skill Credits", snapshot.RemainingSkillCredits); + + AddSkillBucket(headerTemplate, pairTemplate, view, snapshot, "Specialized Skills", ChargenSkillAdvancementClass.Specialized); + AddSkillBucket(headerTemplate, pairTemplate, view, snapshot, "Trained Skills", ChargenSkillAdvancementClass.Trained); + } + + private void AddLine(UiTemplateListEntry template, string text) + { + if (ResolveTemplateChild(template, SingleLineTextId) is { } child) + SetLine(child, text); + } + + private void AddHeader(UiTemplateListEntry template, string text) + { + if (ResolveTemplateChild(template, HeaderTextId) is { } child) + SetLine(child, text); + } + + private void AddPair(UiTemplateListEntry template, string key, int value) + { + UiElement? row = ResolveTemplateRow(template); + if (row is null) + return; + if (UiElement.FindDescendant(row, KeyTextId) is UiText keyText) + SetLine(keyText, key); + if (UiElement.FindDescendant(row, ValueTextId) is UiText valueText) + SetLine(valueText, value.ToString(CultureInfo.InvariantCulture)); + } + + private static void SetLine(UiText text, string content) => + text.LinesProvider = () => [new UiText.Line(content, text.DefaultColor)]; + + private UiElement? ResolveTemplateRow(UiTemplateListEntry template) + { + if (_list is null || _list.TemplateResolver is null) + return null; + UiElement? row = _list.TemplateResolver(template.TemplateLayoutId, template.TemplateElementId); + if (row is null) + return null; + _list.AddPrebuiltRow(row); + return row; + } + + private UiText? ResolveTemplateChild(UiTemplateListEntry template, uint childId) + { + UiElement? row = ResolveTemplateRow(template); + return row is null ? null : UiElement.FindDescendant(row, childId) as UiText; + } + + /// + /// Review fix round F3 (2026-08-16), byte-decoded against + /// SetSummaryText @ ~0x0047b6be-0x0047b9e0: retail adds each + /// bucket's HEADER row UNCONDITIONALLY, before it ever walks + /// skillRecordList for that bucket (an empty bucket still shows + /// its header) — the previous lazy "only if any skill matched" gate had + /// no decomp support. Each matching skill row uses template 2 (the + /// key/value pair, AddItemFromTemplateList(..., 2, ...) @ + /// 0x0047b938), not template 0's single line — KEY = the skill + /// name, VALUE = CharGenState::GetSkillScore(state, skill->id) @ + /// 0x0047b923, ported as . + /// + private void AddSkillBucket( + UiTemplateListEntry headerTemplate, + UiTemplateListEntry pairTemplate, + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot, + string header, + ChargenSkillAdvancementClass targetClass) + { + AddHeader(headerTemplate, header); + for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++) + { + if (view.GetSkillLevel(skillId) != targetClass) + continue; + uint score = _bindings.GetSkillScore?.Invoke(skillId, snapshot.Attributes, targetClass) ?? 0u; + AddPair(pairTemplate, ItemAppraisalTextFormatter.SkillName((int)skillId), (int)score); + } + } + + private static string ProfessionName(ChargenHeritageOptions heritage, uint template) => + template != RuntimeCharacterCreationSnapshot.TemplateUnset + && template < (uint)heritage.Templates.Count + ? heritage.Templates[(int)template].Name + : "None"; + + private static string GenderName(ChargenHeritageOptions heritage, uint genderKey) => + heritage.GendersByKey.TryGetValue((int)genderKey, out ChargenGenderOptions? gender) + ? gender.Name + : "None"; + + private static string StarterAreaName(ChargenOptions options, int startArea) => + startArea >= 0 && startArea < options.StarterAreas.Count + ? options.StarterAreas[startArea].Name + : "None"; + + // ── Preview (own gmCG3DView — InitializePage @0x0047bbf0, camera set + + // ── SetPlayerHeading(180) + StartAnimation, an idle-animated view) ─── + + private void RebuildPreview( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + if (PreviewControl is null + || snapshot.HeritageId == 0u + || snapshot.GenderKey == 0u) + { + return; + } + + RuntimeCharacterCreationAppearance a = snapshot.Appearance; + var selection = new ChargenAppearanceSelection( + a.EyesStrip, a.NoseStrip, a.MouthStrip, + a.HairStyle, a.HairColor, a.EyeColor, + a.HeadgearStyle, a.HeadgearColor, + a.ShirtStyle, a.ShirtColor, + a.TrousersStyle, a.TrousersColor, + a.FootwearStyle, a.FootwearColor, + a.SkinShade, a.HairShade, a.HeadgearShade, + a.ShirtShade, a.TrousersShade, a.FootwearShade); + + PreviewControl.Rebuild(view.Options, snapshot.HeritageId, (int)snapshot.GenderKey, selection); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + if (_nameField is not null) + { + _nameField.OnFocusLost = null; + _nameField.OnSubmit = null; + } + if (_nameTooLongDialogContext != 0u) + { + uint closing = _nameTooLongDialogContext; + _nameTooLongDialogContext = 0u; + _dialogs.CloseDialog(closing); + } + if (_list is not null) + _list.TemplateResolver = null; + _list?.Flush(); + // PreviewControl is owned by the composition root (disposed with + // the leased ChargenPreviewRenderer) — just drop the reference. + PreviewControl = null; + } +} diff --git a/src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs new file mode 100644 index 00000000..afdea3ea --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs @@ -0,0 +1,171 @@ +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.App.UI.Layout; + +/// +/// The Town page (gmCGTownPage, root 0x100003d5) — the four +/// starting-area buttons. Decomp anchors: +/// gmCGTownPage::InitializePage @ 0x0047c6d0 (button ids), +/// gmCGTownPage::SetTown @ 0x0047c360 (button -> +/// CharGenState::SetStartArea(arg2 - 1) literal index map: Holtburg +/// -> 0, Shoushi -> 1, Yaraq -> 2, Sanamar -> 3), +/// gmCGTownPage::ListenToElementMessage @ 0x0047c480 (Sanamar's +/// AccountHasThroneOfDestiny gate — acdream has no account/DLC +/// signal, so it ships without the gate; register AD-102, same row as the +/// Heritage page's Viamontian gate), gmCGTownPage::SetTownString @ +/// 0x0047c1f0 (composed description text). +/// +internal sealed class CharacterCreationTownPage : IDisposable +{ + /// Button element id -> the LITERAL startArea index + /// gmCGTownPage::SetTown sends — retail hardcodes these four + /// indices directly rather than looking them up by name, so this port + /// does too. + private static readonly IReadOnlyDictionary StartAreaByButtonId = + new Dictionary + { + [0x1000040Du] = 0, // Holtburg + [0x1000040Fu] = 1, // Shoushi + [0x1000040Eu] = 2, // Yaraq + [0x1000040Bu] = 3, // Sanamar (ToD-gated in retail; see class doc) + }; + + private static readonly IReadOnlyDictionary TownTextKeyByStartArea = + new Dictionary + { + [0] = "ID_CharGen_HoltText", + [1] = "ID_CharGen_ShoushiText", + [2] = "ID_CharGen_YaraqText", + [3] = "ID_CharGen_SanamarText", + }; + + /// + /// Start-area index -> the page's OWN retail state literal — a + /// SEPARATE state machine from CharacterCreationUiController's + /// master-page per-page-index cycling + /// (0x10000025 + (page - 1)). gmCGTownPage::SetTown @ + /// 0x0047c360 calls this->vtable->SetState(...) (the + /// gmCGTownPage/page-root object itself) with these four literals + /// verbatim, alongside the per-button highlight state — note these do + /// NOT sit in button/startArea numeric order: Holtburg->0x10000034, + /// Shoushi->0x10000037, Yaraq->0x10000036, Sanamar->0x10000035. + /// Re-asserted directly (inlined, bypassing SetTown) at the Sanamar + /// click site @0x0047c518. Review fix round F4 (2026-08-15): only the + /// master page's state cycling was ported — this page's own state was + /// missed entirely. + /// + private static readonly IReadOnlyDictionary PageStateByStartArea = + new Dictionary + { + [0] = 0x10000034u, // Holtburg + [1] = 0x10000037u, // Shoushi + [2] = 0x10000036u, // Yaraq + [3] = 0x10000035u, // Sanamar + }; + + private readonly CharacterCreationRuntimeBindings _bindings; + private readonly UiElement _pageRoot; + private readonly Dictionary _buttons = []; + private readonly UiText? _description; + private bool _disposed; + + internal CharacterCreationTownPage( + UiElement pageRoot, + CharacterCreationRuntimeBindings bindings) + { + _bindings = bindings; + _pageRoot = pageRoot; + foreach ((uint buttonId, int startArea) in StartAreaByButtonId) + { + if (UiElement.FindDescendant(pageRoot, buttonId) is not UiButton button) + continue; + _buttons[button] = startArea; + button.OnClick = () => Select(startArea); + } + + _description = UiElement.FindDescendant(pageRoot, 0x10000409u) as UiText; + } + + internal void Refresh( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + foreach ((UiButton button, int startArea) in _buttons) + button.Selected = startArea == snapshot.StartArea; + + if (PageStateByStartArea.TryGetValue(snapshot.StartArea, out uint pageStateId) + && _pageRoot is IUiDatStateful stateful) + { + stateful.TrySetRetailState(pageStateId); + } + + if (_description is null) + return; + + // GF-11a: gmCGTownPage::SetTownString @ 0x0047c1f0 concatenates + // howTo + a compiled "\n\n%s\n" literal format around the town + // text (the ONE composition site in this batch where retail's OWN + // code — not the authored DAT string content — inserts the blank + // line, confirmed via the format string's raw bytes, + // 0x0079c2d2 = u"\n\n%s\n") into ONE plain SetText — no per-run + // font/color argument, unlike Heritage's WithFont calls. The + // string composition itself was already byte-correct before this + // fix; what was missing was routing it through the same + // escape-normalize + word-wrap path every other description box + // needed (a single un-wrapped line meant the town-specific suffix + // rendered past the clipped viewport, which is why switching towns + // looked like the text never changed). + string composed = ComposeDescription(snapshot.StartArea, _bindings.ResolveText); + var segments = new[] { new DatRichText.Segment(composed, _description.DefaultColor) }; + // F11 (Campaign CC gate round 1 closeout): compose ONCE here + // (Refresh is already revision-gated) instead of re-wrapping on + // every draw call — see CharacterCreationHeritagePage.Refresh's own + // comment for the full rationale. + IReadOnlyList composedLines = DatRichText.Compose(_description, segments); + _description.LinesProvider = () => composedLines; + } + + internal void Randomize(IRuntimeCharacterCreationView view) + { + // CharGenState::SetStartArea(RandInt(hasToD ? 4 : 3)) — acdream + // always treats ToD as owned (see the class doc's AD-102 note), so + // this picks uniformly across all 4 literal indices (register + // AP-212 for the Random approximation itself), clamped to however + // many starter areas the installed DAT actually carries. + int bound = Math.Min(4, view.Options.StarterAreas.Count); + if (bound <= 0) + return; + Select(Random.Shared.Next(bound)); + } + + private void Select(int startArea) + { + if (_disposed) + return; + _bindings.SelectStartArea(startArea); + } + + private static string ComposeDescription(int startArea, Func? resolveText) + { + if (resolveText is null) + return string.Empty; + string? howTo = resolveText("ID_CharGen_TownHowTo"); + string? townText = TownTextKeyByStartArea.TryGetValue(startArea, out string? key) + ? resolveText(key) + : null; + if (howTo is null && townText is null) + return string.Empty; + return $"{howTo}\n\n{townText}\n"; + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + foreach (UiButton button in _buttons.Keys) + button.OnClick = null; + _buttons.Clear(); + } +} diff --git a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs new file mode 100644 index 00000000..7cbe7810 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs @@ -0,0 +1,1134 @@ +using System.Numerics; +using AcDream.Core.CharGen; +using AcDream.Core.Net.Messages; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.App.UI.Layout; + +/// +/// Bindings the retail character-creation screen (gmCharGenMainUI) +/// needs beyond the borrowed view: generation-capturing command wrappers, +/// mirroring 's shape exactly. +/// Every Func here is a late-bound seam (Campaign CC — see +/// feedback_resolve_deferred_funcs_per_call.md): callers MUST resolve +/// it per-call, never capture the delegate once at mount time. +/// +/// Campaign CC slice CC4: the retail +/// transition is Create Character (0x100003A0) → +/// QueueUIMode(0x1000000b), but that button stays ghosted until CC7's +/// closing move. This flag is the interim env/test-only open seam +/// (ACDREAM_OPEN_CHARGEN=1) +/// so the screen can be exercised before the real button is wired. +public sealed record CharacterCreationRuntimeBindings( + Func View, + Func SelectHeritage, + Func SelectGender, + Func SelectTemplate, + Func SetAttribute, + Func SetAttributeLock, + Func TrainSkill, + Func SpecializeSkill, + Func UntrainSkill, + Func SelectStartArea, + Func Finish, + Action RequestExit, + /// Campaign CC slice CC6b-MOUNT: the Appearance page's nine + /// spin controls and nine color swatches. + Func? SetAppearanceIndex = null, + /// CC6b-MOUNT: the Appearance page's shade scrollbar. + Func? SetShade = null, + /// DAT string lookup (table 0x23000002, the SAME table + /// every other ID_CharGen_*/ID_Character* key resolves + /// through) — used by the Heritage page's composed description text. + /// degrades to the heritage's own DAT + /// Name field instead of the full composed copy. + Func? ResolveText = null, + /// Campaign CC slice CC5: the Summary page's name field + /// commit (gmCGSummaryPage::ListenToElementMessage's + /// CharGenState::SetName call). + Func? SetName = null, + /// CC5: dismisses a surfaced 0xF643 rejection after its + /// dialog closes (RuntimeCharacterCreationState.TryAcknowledgeRejection). + Func? AcknowledgeRejection = null, + /// CC5: the screen-open roll + /// (gmCharGenMainUI's ctor-time RandomizeCharacter call) + /// and the Summary page's Random button. + Func? RandomizeCharacter = null, + /// CC5: the Appearance page's Random button on its Face + /// sub-tab. + Func? RandomizeAppearance = null, + /// CC5: the Appearance page's Random button on its Clothes + /// sub-tab. + Func? RandomizeClothing = null, + /// CC5 review fix round, F3 (2026-08-16): the Summary page's + /// skill-row VALUE — CharGenState::GetSkillScore @ 0x005C4B50, + /// wired at composition time (AcDream.App.Net.ChargenSkillScoreResolver) + /// so this UI-layer record stays free of a direct DAT/Chorizite + /// dependency, matching 's own shape. + /// degrades to a "no score available" 0. + Func? GetSkillScore = null, + bool OpenOnStart = false); + +/// +/// Projects Runtime's borrowed +/// through retail gmCharGenMainUI's authored retained layout — the +/// mount + master shell (progress bar, tab strip, Back/Next/Finish/Help/ +/// Exit/Random nav) plus the Heritage/Profession/Skills/Town pages this +/// slice builds. Fix round F6: the Appearance (0x100003d4) page root +/// is fully LIVE as of CC6b-MOUNT (); +/// only the Summary (0x100003d6) page root remains mounted but +/// content-inert — CC5 fills it (register TS-82, narrowed to Summary-only +/// at CC6b-MOUNT). +/// +/// +/// Decomp anchors: root construction + child resolution +/// gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0 (root element +/// 0x100003cc from enum 0x10000039); page switching +/// gmCharGenMainUI::SetProgressState @ 0x004e7a10 (the Olthoi +/// tab-hiding + redirect logic); nav dispatch +/// gmCharGenMainUI::ListenToElementMessage @ 0x004e9450; exit +/// confirmation gmCharGenMainUI::DoExit @ 0x004e8650; randomize +/// dispatch gmCharGenMainUI::DoRandom @ 0x004e7d70. +/// +/// +internal sealed class CharacterCreationUiController : IDisposable +{ + internal const uint RootEnum = 0x10000039u; + internal const uint RootElementId = 0x100003CCu; + internal const uint ProgressBarElementId = 0x100003CEu; + internal const uint BackElementId = 0x100003C6u; + internal const uint NextElementId = 0x100003C7u; + internal const uint FinishElementId = 0x100003C8u; + internal const uint HelpElementId = 0x100003C9u; + internal const uint ExitElementId = 0x100003CAu; + internal const uint RandomElementId = 0x100003CBu; + internal const uint MasterPageElementId = 0x100003D0u; + internal const uint HeritagePageElementId = 0x100003D1u; + internal const uint ProfessionPageElementId = 0x100003D2u; + internal const uint SkillsPageElementId = 0x100003D3u; + internal const uint AppearancePageElementId = 0x100003D4u; + internal const uint TownPageElementId = 0x100003D5u; + internal const uint SummaryPageElementId = 0x100003D6u; + internal const uint HeritageTabElementId = 0x100003EFu; + internal const uint ProfessionTabElementId = 0x100003F0u; + internal const uint SkillsTabElementId = 0x100003F1u; + internal const uint AppearanceTabElementId = 0x100003F2u; + internal const uint TownTabElementId = 0x100003F3u; + internal const uint SummaryTabElementId = 0x100003F4u; + + /// Retail's gmCharGenMainUI::ECGProgress enum values — + /// used verbatim as the master page's per-page state ids + /// (0x10000025 + (page - 1)) and the tab-hide/redirect math in + /// . + internal enum Page + { + Heritage = 1, + Profession = 2, + Skills = 3, + Appearance = 4, + Town = 5, + Summary = 6, + } + + internal sealed record DialogStrings( + string ExitWarning, + /// Campaign CC slice CC5: ID_CharGen_NoNameWarning — + /// DoFinish's empty-name refusal dialog. + string NoNameWarning, + /// CC5: ID_CharGen_CreditWarning — + /// MakeCreditWarningDialog's unspent-attribute-credits + /// confirmation. + string CreditWarning, + /// CC5: ID_CharGen_RandomizeWarning — + /// MakeRandomizeWarningDialog's Summary-page Random + /// confirmation. + string RandomizeWarning, + /// CC5: ID_CharGen_NameTooLong — + /// gmCGSummaryPage::DoNameLimitDialog's name-field-too-long + /// notice. + string NameTooLong); + + private readonly UiRoot _host; + private readonly ImportedLayout _layout; + private readonly UiElement _progressBar; + private readonly UiButton _back; + private readonly UiButton _next; + private readonly UiButton _finish; + private readonly UiButton _help; + private readonly UiButton _exit; + private readonly UiButton _random; + private readonly UiElement _masterPage; + private readonly UiElement _heritagePageRoot; + private readonly UiElement _professionPageRoot; + private readonly UiElement _skillsPageRoot; + private readonly UiElement _appearancePageRoot; + private readonly UiElement _townPageRoot; + private readonly UiElement _summaryPageRoot; + private readonly UiButton _heritageTab; + private readonly UiButton _professionTab; + private readonly UiButton _skillsTab; + private readonly UiButton _appearanceTab; + private readonly UiButton _townTab; + private readonly UiButton _summaryTab; + private readonly RetailDialogFactory _dialogs; + private readonly CharacterCreationRuntimeBindings _bindings; + private readonly DialogStrings _strings; + private readonly CharacterCreationHeritagePage _heritagePage; + private readonly CharacterCreationProfessionPage _professionPage; + private readonly CharacterCreationSkillsPage _skillsPage; + private readonly CharacterCreationTownPage _townPage; + private readonly CharacterCreationAppearancePage _appearancePage; + private readonly CharacterCreationSummaryPage _summaryPage; + + private Vector2 _authoredCanvas; + private RuntimeGenerationToken _lastGeneration; + private long _lastRevision = long.MinValue; + private Page _currentPage = Page.Heritage; + private bool _active; + private bool _isOpen; + private bool _openOnStartConsumed; + private uint _exitDialogContext; + // Campaign CC slice CC5: gmCharGenMainUI's own m_uiCreditWarningContext/ + // m_uiRandomizeWarningContext (0x004e8870/0x004e8a90) — same + // one-outstanding-dialog-at-a-time guard shape as _exitDialogContext. + private uint _creditWarningDialogContext; + private uint _randomizeWarningDialogContext; + private uint _noNameWarningDialogContext; + // CC5 review fix round F4 (2026-08-16): gmCharGenMainUI's own + // m_uiErrorMessageContext (MakeErrorMessageDialog @ 0x004e8cb0's guard + // at 0x004e8cc4, assigned at 0x004e8dd3, cleared by the dtor at + // 0x004e83b3 alongside m_uiPleaseWaitContext/m_uiExitContext) — the + // 0xF643 rejection dialog was the only one of the five dialogs this + // controller owns without this same one-outstanding-dialog guard. + private uint _errorMessageDialogContext; + private RuntimeCharacterCreationRejection? _lastShownRejection; + private bool _suppressDialogCallbacks; + private bool _disposed; + + private CharacterCreationUiController( + UiRoot host, + ImportedLayout layout, + UiElement progressBar, + UiButton back, + UiButton next, + UiButton finish, + UiButton help, + UiButton exit, + UiButton random, + UiElement masterPage, + UiElement heritagePageRoot, + UiElement professionPageRoot, + UiElement skillsPageRoot, + UiElement appearancePageRoot, + UiElement townPageRoot, + UiElement summaryPageRoot, + UiButton heritageTab, + UiButton professionTab, + UiButton skillsTab, + UiButton appearanceTab, + UiButton townTab, + UiButton summaryTab, + Func templateResolver, + RetailDialogFactory dialogs, + CharacterCreationRuntimeBindings bindings, + DialogStrings strings) + { + _host = host; + _layout = layout; + _progressBar = progressBar; + _back = back; + _next = next; + _finish = finish; + _help = help; + _exit = exit; + _random = random; + _masterPage = masterPage; + _heritagePageRoot = heritagePageRoot; + _professionPageRoot = professionPageRoot; + _skillsPageRoot = skillsPageRoot; + _appearancePageRoot = appearancePageRoot; + _townPageRoot = townPageRoot; + _summaryPageRoot = summaryPageRoot; + _heritageTab = heritageTab; + _professionTab = professionTab; + _skillsTab = skillsTab; + _appearanceTab = appearanceTab; + _townTab = townTab; + _summaryTab = summaryTab; + _dialogs = dialogs; + _bindings = bindings; + _strings = strings; + + Root.Left = 0f; + Root.Top = 0f; + Root.ClickThrough = false; + Root.Visible = false; + // AD-98: the same authored 800x600 fixed-canvas treatment as the + // character-management screen. Both controllers DECLARE/REVOKE + // through UiRoot's owner-scoped arbiter (review fix round R1, + // 2026-08-15) rather than writing UiRoot.FixedCanvasSize directly — + // char-management can be simultaneously active underneath this + // screen, and a raw write from either controller is a last-writer- + // wins race with no owner (the F1 fix's own Close() null wiped + // char-management's still-active canvas out from under it). See + // Open/Close/Deactivate/Dispose below for the matching declare/ + // revoke pair. + _authoredCanvas = new Vector2( + Root.Width > 0f ? Root.Width : 800f, + Root.Height > 0f ? Root.Height : 600f); + + _heritagePage = new CharacterCreationHeritagePage(heritagePageRoot, bindings, ApplyHeritageTabRestore); + _professionPage = new CharacterCreationProfessionPage(professionPageRoot, bindings); + _skillsPage = new CharacterCreationSkillsPage(skillsPageRoot, bindings, templateResolver); + _townPage = new CharacterCreationTownPage(townPageRoot, bindings); + _appearancePage = new CharacterCreationAppearancePage(appearancePageRoot, bindings); + _summaryPage = new CharacterCreationSummaryPage( + summaryPageRoot, bindings, dialogs, strings.NameTooLong, templateResolver); + + // gmCharGenMainUI::ListenToElementMessage @ 0x004e9450. + _back.OnClick = OnBack; + _next.OnClick = OnNext; + // Finish (0x100003c8): retail enables it on Summary only + // (ListenToElementMessage's case 0x100003c8 no-ops unless + // m_eProgressState == ECG_SUMMARY @ 0x004e956f) — ApplyProgressState + // gates _finish.Enabled the same way. OnFinish itself re-checks the + // current page defensively (mirroring that same retail guard). + _finish.OnClick = OnFinish; + // Help (0x100003c9) is not handled in gmCharGenMainUI's own + // ListenToElementMessage switch (case 0x100003c9 falls straight + // through to the base UIFramework handler) — retail has no custom + // help action here either; leave it a no-op. + _help.OnClick = null; + _exit.OnClick = OnExit; + _random.OnClick = OnRandom; + _heritageTab.OnClick = () => ApplyProgressState(Page.Heritage); + _professionTab.OnClick = () => ApplyProgressState(Page.Profession); + _skillsTab.OnClick = () => ApplyProgressState(Page.Skills); + _appearanceTab.OnClick = () => ApplyProgressState(Page.Appearance); + _townTab.OnClick = () => ApplyProgressState(Page.Town); + _summaryTab.OnClick = () => ApplyProgressState(Page.Summary); + + // GF-13 (Campaign CC gate round 1, Batch A): honor the authored + // Invisible flag (dat property 0x3B) chargen-scoped only — see + // HideAuthoredInvisibleElements's own doc comment. + HideAuthoredInvisibleElements(Root); + } + + /// + /// GF-13 (Campaign CC gate round 1, Batch A). The user's live gate + /// reported an acdream-only "-Non-admin or Non-envoy" text leak below the + /// Summary name field. Root cause: elements 0x10000403 ("Non- + /// Admin") and 0x10000494 ("Non-Envoy") author dat property + /// 0x3B (Invisible) = — retail's + /// UIElement::OnSetAttribute @0x00462d80 case 8 + /// (GetPropertyName()-0x33 == 8, property id 0x3B) hides any + /// element authoring it via SetVisible(value == 0). acdream's + /// shared never read this property at all + /// (it now does, into / + /// , a pure data addition), so + /// every one of the 1,083 elements client-wide that author it rendered + /// regardless. A blanket importer-wide honor is its own separately-gated + /// visual sweep (docs/ISSUES.md #408) — this method is the NARROW, + /// chargen-scoped fix: walk this screen's own mounted subtree once at + /// construction and hide anything the dat itself marked hidden, by the + /// AUTHORED FLAG rather than a hardcoded id list, so any other + /// authored-invisible element under this root (not just the two the user + /// happened to see) is honored the same way. Register AP-230 records the + /// scoped-vs-general split. + /// + private static void HideAuthoredInvisibleElements(UiElement element) + { + if (element.AuthoredInvisible) + element.Visible = false; + foreach (UiElement child in element.Children) + HideAuthoredInvisibleElements(child); + } + + internal UiElement Root => _layout.Root; + + /// The authored Appearance-page viewport (0x100003bb) — + /// CC6b-MOUNT's composition root assigns its Renderer once the + /// graphics backend exists (mirrors the paperdoll's own late + /// viewport.Renderer = ... assignment). + internal UiViewport? AppearanceViewport => _appearancePage.Viewport; + + /// CC6b-MOUNT: the late-bound zoom/rotate control surface — + /// see 's own + /// doc comment for why this is assigned after construction rather than + /// threaded through the ctor. + internal AcDream.App.Rendering.IChargenPreviewControl? AppearancePreviewControl + { + get => _appearancePage.PreviewControl; + set => _appearancePage.PreviewControl = value; + } + + /// Campaign CC gate round 1 closeout (Group 1, R2-5): the same + /// late-bound pattern as above, + /// for the real color-wheel/swatch-color mechanism's three DAT-backed + /// seams — see 's + /// own doc comment. + internal IChargenPalSetSource? AppearancePalSetSource + { + get => _appearancePage.PalSetSource; + set => _appearancePage.PalSetSource = value; + } + + internal IChargenClothingTableSource? AppearanceClothingTableSource + { + get => _appearancePage.ClothingTableSource; + set => _appearancePage.ClothingTableSource = value; + } + + internal IChargenPaletteColorSource? AppearancePaletteColorSource + { + get => _appearancePage.PaletteColorSource; + set => _appearancePage.PaletteColorSource = value; + } + + /// R3-5/R3-6 (Campaign CC gate round 1 re-test 2): the fourth + /// late-bound seam, same pattern as the three above — see + /// 's + /// own doc comment. + internal IChargenSwatchTextureSource? AppearanceSwatchTextureSource + { + get => _appearancePage.SwatchTextureSource; + set => _appearancePage.SwatchTextureSource = value; + } + + /// Gates the Appearance preview's per-frame work on whether + /// that specific page — AND the whole chargen screen — is the one + /// currently showing. Close() only ever hides , + /// not the individual page roots, so a page-root-only check would stay + /// true after the screen closes on the Appearance page. Mirrors the + /// paperdoll's own outer-inventory-frame gate. + internal bool IsAppearancePageVisible => Root.Visible && _appearancePageRoot.Visible; + + /// Campaign CC slice CC5: the authored Summary-page viewport + /// (0x10000406) — its OWN gmCG3DView instance, distinct + /// from the Appearance page's (see this class's own class doc on the + /// decomp citation). + internal UiViewport? SummaryViewport => _summaryPage.Viewport; + + /// CC5: the Summary preview's late-bound control surface. No + /// zoom/rotate buttons bind against it — see + /// 's + /// doc comment. + internal AcDream.App.Rendering.IChargenPreviewControl? SummaryPreviewControl + { + get => _summaryPage.PreviewControl; + set => _summaryPage.PreviewControl = value; + } + + /// CC5: same shape as , + /// for the Summary page. + internal bool IsSummaryPageVisible => Root.Visible && _summaryPageRoot.Visible; + + internal static CharacterCreationUiController? CreateDetached( + UiRoot host, + ImportedLayout layout, + Func templateResolver, + RetailDialogFactory dialogs, + CharacterCreationRuntimeBindings bindings, + DialogStrings strings) + { + ArgumentNullException.ThrowIfNull(host); + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(templateResolver); + ArgumentNullException.ThrowIfNull(dialogs); + ArgumentNullException.ThrowIfNull(bindings); + ArgumentNullException.ThrowIfNull(strings); + + if (layout.Root.DatElementId != RootElementId + || layout.FindElement(ProgressBarElementId) is not { } progressBar + || layout.FindElement(BackElementId) is not UiButton back + || layout.FindElement(NextElementId) is not UiButton next + || layout.FindElement(FinishElementId) is not UiButton finish + || layout.FindElement(HelpElementId) is not UiButton help + || layout.FindElement(ExitElementId) is not UiButton exit + || layout.FindElement(RandomElementId) is not UiButton random + || layout.FindElement(MasterPageElementId) is not { } masterPage + || layout.FindElement(HeritagePageElementId) is not { } heritagePageRoot + || layout.FindElement(ProfessionPageElementId) is not { } professionPageRoot + || layout.FindElement(SkillsPageElementId) is not { } skillsPageRoot + || layout.FindElement(AppearancePageElementId) is not { } appearancePageRoot + || layout.FindElement(TownPageElementId) is not { } townPageRoot + || layout.FindElement(SummaryPageElementId) is not { } summaryPageRoot + || layout.FindElement(HeritageTabElementId) is not UiButton heritageTab + || layout.FindElement(ProfessionTabElementId) is not UiButton professionTab + || layout.FindElement(SkillsTabElementId) is not UiButton skillsTab + || layout.FindElement(AppearanceTabElementId) is not UiButton appearanceTab + || layout.FindElement(TownTabElementId) is not UiButton townTab + || layout.FindElement(SummaryTabElementId) is not UiButton summaryTab) + { + Console.WriteLine( + "[UI] character creation: the authored root/master-shell contract is incomplete."); + return null; + } + + return new CharacterCreationUiController( + host, + layout, + progressBar, + back, + next, + finish, + help, + exit, + random, + masterPage, + heritagePageRoot, + professionPageRoot, + skillsPageRoot, + appearancePageRoot, + townPageRoot, + summaryPageRoot, + heritageTab, + professionTab, + skillsTab, + appearanceTab, + townTab, + summaryTab, + templateResolver, + dialogs, + bindings, + strings); + } + + internal void AttachAndTick() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (Root.Parent is null) + _host.AddChild(Root); + Tick(); + } + + internal void Tick() + { + if (_disposed) + return; + + IRuntimeCharacterCreationView? view = _bindings.View(); + RuntimeCharacterCreationSnapshot snapshot = view?.Snapshot ?? default; + if (view is null || !snapshot.IsActive) + { + Deactivate(); + _lastGeneration = snapshot.Generation; + _lastRevision = snapshot.Revision; + return; + } + + if (!_active) + { + _active = true; + // CC4 interim open seam (ACDREAM_OPEN_CHARGEN=1) — the real + // Create-button transition is CC7's. Fires once per mount. + if (_bindings.OpenOnStart && !_openOnStartConsumed) + { + _openOnStartConsumed = true; + Open(); + } + } + + if (_isOpen) + { + Root.Visible = true; + _host.BringToFront(Root); + } + else + { + Root.Visible = false; + } + + if (_lastGeneration != snapshot.Generation + || _lastRevision != snapshot.Revision) + { + _heritagePage.Refresh(view, snapshot); + _professionPage.Refresh(view, snapshot); + _skillsPage.Refresh(view, snapshot); + _townPage.Refresh(view, snapshot); + _appearancePage.Refresh(view, snapshot); + _summaryPage.Refresh(view, snapshot); + _lastGeneration = snapshot.Generation; + _lastRevision = snapshot.Revision; + } + + ReconcileDialogs(snapshot); + } + + /// Opens the screen at retail's authored default page + /// (gmCharGenMainUI::gmCharGenMainUI's trailing + /// SetProgressState(this, ECG_HERTAGE)). Declares the fixed + /// canvas on this exact activation edge through 's + /// arbiter — matching 's + /// own one-shot declare — not per-tick; / + /// / revoke it back out + /// symmetrically, and the canvas stays set for as long as ANY other + /// declarer (e.g. character-management underneath) remains active. + internal void Open() + { + if (_disposed) + return; + _isOpen = true; + _host.DeclareFixedCanvas(this, _authoredCanvas); + RollOpeningCharacter(); + ApplyProgressState(Page.Heritage); + } + + /// + /// Campaign CC slice CC5: ports gmCharGenMainUI's ctor-time roll + /// (~0x004e81f5-0x004e8218) — CharGenState::RandomizeCharacter + /// (state, hasToD) @ 0x005c6d80 runs BEFORE any page constructs, + /// retiring AP-214's honest-blank deviation (retail's chargen screen is + /// never actually blank on open). Then reproduces + /// gmCGAppearancePage::InitializePage's own gender-read-then-FLIP + /// (~0x004802da-0x00480303, decomp-confirmed: + /// mGender==1 -> SetGender(2), mGender==2 -> SetGender(1)) — + /// a genuine, always-firing retail quirk that runs immediately AFTER + /// RandomizeCharacter already assigned a real (non-zero) gender. + /// Retail's whole UI tree (every page, including Appearance) is + /// reconstructed fresh each time the chargen screen opens, so the flip + /// fires once per visit there; acdream's pages are built once at mount + /// time and only toggle visibility, so — the closest + /// analogue to "runs once per screen-open" this architecture has — is + /// where both the roll and the flip belong. + /// + private void RollOpeningCharacter() + { + if (_bindings.RandomizeCharacter?.Invoke().Status != RuntimeCommandStatus.Accepted) + return; + + uint gender = _bindings.View()?.Snapshot.GenderKey ?? 0u; + if (gender == 1u) + _bindings.SelectGender(2u); + else if (gender == 2u) + _bindings.SelectGender(1u); + } + + private void Close() + { + if (!_isOpen) + return; + _isOpen = false; + Root.Visible = false; + _host.RevokeFixedCanvas(this); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + try + { + CloseAllDialogs(suppressCallbacks: true); + } + finally + { + // Matches CharacterManagementUiController.Dispose's own + // unconditional revoke — defends against disposing while + // _isOpen (Close() is not otherwise called on this path). Idle + // if Close() already revoked (RevokeFixedCanvas is a no-op for + // an owner that already revoked). + _host.RevokeFixedCanvas(this); + _back.OnClick = null; + _next.OnClick = null; + _finish.OnClick = null; + _help.OnClick = null; + _exit.OnClick = null; + _random.OnClick = null; + _heritageTab.OnClick = null; + _professionTab.OnClick = null; + _skillsTab.OnClick = null; + _appearanceTab.OnClick = null; + _townTab.OnClick = null; + _summaryTab.OnClick = null; + _heritagePage.Dispose(); + _professionPage.Dispose(); + _skillsPage.Dispose(); + _townPage.Dispose(); + _appearancePage.Dispose(); + _summaryPage.Dispose(); + _host.RemoveChild(Root); + } + } + + // ── Nav dispatch (gmCharGenMainUI::ListenToElementMessage @ 0x004e9450) ── + + private void OnBack() + { + if (_disposed) + return; + if (_currentPage <= Page.Heritage) + { + OnExit(); + return; + } + ApplyProgressState(_currentPage - 1); + } + + private void OnNext() + { + if (_disposed) + return; + if (_currentPage < Page.Summary) + ApplyProgressState(_currentPage + 1); + } + + private void OnExit() + { + if (_disposed) + return; + // gmCharGenMainUI::DoExit @ 0x004e8650's own guard: a second Exit + // click while the confirmation is already open is a no-op. + if (_exitDialogContext != 0u) + return; + + _exitDialogContext = _dialogs.MakeConfirmation( + _strings.ExitWarning, + data => + { + _exitDialogContext = 0u; + if (_disposed || _suppressDialogCallbacks) + return; + + // RecvNotice_CloseDialog @ 0x004e9780's exit-context branch: + // confirm -> QueueUIMode(0x1000000a) (leave chargen). Our + // equivalent is closing this screen; whatever mounted the + // character-management screen already keeps re-drawing it + // underneath (this screen only BringToFront's itself while + // open — see Tick). + if (data.GetBoolean(RetailDialogProperty.ConfirmationResult)) + { + Close(); + _bindings.RequestExit(); + } + }); + } + + private void OnRandom() + { + if (_disposed) + return; + + // gmCharGenMainUI::DoRandom @ 0x004e7d70. Heritage/Profession/Town + // still use the AP-212 uniform-pick approximation (unchanged this + // slice); Appearance now delegates to the page's own real + // RandomizeAppearance/RandomizeClothing primitives (CC5); Skills' + // CharGenState::RandomizeSkills remains unported (AP-212, narrowed) — + // _random.Enabled already keeps the control ghosted there + // (ApplyProgressState). Summary goes through + // gmCharGenMainUI::MakeRandomizeWarningDialog @ 0x004e8a90 first — + // that dialog + its confirm-triggered RandomizeCharacter call are + // gmCharGenMainUI's OWN methods in retail (not gmCGSummaryPage's), + // so they live here on the master controller. + IRuntimeCharacterCreationView? view = _bindings.View(); + if (view is null) + return; + RuntimeCharacterCreationSnapshot snapshot = view.Snapshot; + + switch (_currentPage) + { + case Page.Heritage: + _heritagePage.Randomize(snapshot); + break; + case Page.Profession: + _professionPage.Randomize(snapshot); + break; + case Page.Appearance: + _appearancePage.Randomize(); + break; + case Page.Town: + _townPage.Randomize(view); + break; + case Page.Summary: + ShowRandomizeWarningDialog(); + break; + } + } + + /// Ports gmCharGenMainUI::MakeRandomizeWarningDialog @ + /// 0x004e8a90 (ID_CharGen_RandomizeWarning) + + /// CloseRandomizeWarningDialog @ 0x004e8400's own confirm arm + /// (arg2 != 0 -> DoRandom(this), which on THIS second call + /// takes DoRandom's Summary case directly — no re-entrant + /// warning, since the gate lives in the button-click dispatcher above, + /// not inside DoRandom itself). + private void ShowRandomizeWarningDialog() + { + // MakeRandomizeWarningDialog's own guard: a second click while the + // dialog is already open is a no-op. + if (_randomizeWarningDialogContext != 0u) + return; + + _randomizeWarningDialogContext = _dialogs.MakeConfirmation( + _strings.RandomizeWarning, + data => + { + _randomizeWarningDialogContext = 0u; + if (_disposed || _suppressDialogCallbacks) + return; + if (data.GetBoolean(RetailDialogProperty.ConfirmationResult)) + _bindings.RandomizeCharacter?.Invoke(); + }); + } + + // ── Page switching (gmCharGenMainUI::SetProgressState @ 0x004e7a10) ──── + + private void ApplyProgressState(Page target) + { + _heritagePageRoot.Visible = false; + _professionPageRoot.Visible = false; + _skillsPageRoot.Visible = false; + _appearancePageRoot.Visible = false; + _townPageRoot.Visible = false; + _summaryPageRoot.Visible = false; + _next.Visible = true; + _finish.Visible = false; + + Page previous = _currentPage; + _currentPage = target; + _heritageTab.Selected = false; + _professionTab.Selected = false; + _skillsTab.Selected = false; + _appearanceTab.Selected = false; + _townTab.Selected = false; + _summaryTab.Selected = false; + + uint heritageId = _bindings.View()?.Snapshot.HeritageId ?? 0u; + bool isOlthoi = heritageId == (uint)ChargenHeritageGroup.Olthoi + || heritageId == (uint)ChargenHeritageGroup.OlthoiAcid; + if (isOlthoi) + { + _professionTab.Visible = false; + _skillsTab.Visible = false; + _townTab.Visible = false; + if (_currentPage < previous) + { + if (_currentPage is Page.Profession or Page.Skills) + _currentPage = Page.Heritage; + else if (_currentPage == Page.Town) + _currentPage = Page.Appearance; + } + else + { + if (_currentPage is Page.Profession or Page.Skills) + _currentPage = Page.Appearance; + else if (_currentPage == Page.Town) + _currentPage = Page.Summary; + } + } + else + { + _professionTab.Visible = true; + _skillsTab.Visible = true; + _townTab.Visible = true; + } + + SetMasterPageState(0x10000025u + (uint)_currentPage - 1u); + switch (_currentPage) + { + case Page.Heritage: + _heritagePageRoot.Visible = true; + _heritageTab.Selected = true; + break; + case Page.Profession: + _professionPageRoot.Visible = true; + _professionTab.Selected = true; + break; + case Page.Skills: + _skillsPageRoot.Visible = true; + _skillsTab.Selected = true; + break; + case Page.Appearance: + _appearancePageRoot.Visible = true; + _appearanceTab.Selected = true; + break; + case Page.Town: + _townPageRoot.Visible = true; + _townTab.Selected = true; + break; + case Page.Summary: + _summaryPageRoot.Visible = true; + _summaryTab.Selected = true; + _next.Visible = false; + _finish.Visible = true; + break; + } + + // Random (0x100003cb): CC5 ports RandomizeAppearance/RandomizeClothing + // (Appearance) and RandomizeCharacter (Summary), retiring both gaps + // AP-212 used to track for those two pages — only Skills' + // RandomizeSkills remains unported (AP-212, narrowed). + _random.Enabled = _currentPage is not Page.Skills; + // Finish (0x100003c8): retail enables it on Summary only + // (ListenToElementMessage's case 0x100003c8 no-ops off Summary). + _finish.Enabled = _currentPage == Page.Summary; + + _lastRevision = long.MinValue; + Tick(); + } + + private void SetMasterPageState(uint stateId) + { + if (_masterPage is IUiDatStateful stateful) + stateful.TrySetRetailState(stateId); + } + + // ── Heritage tab-restore (gmCharGenMainUI::ListenToElementMessage @ ──── + // ── 0x004e9450, the heritage-button bubble arm) ───────────────────── + + /// SHOW ids (label_4e9673, three SetVisible(1) calls) — + /// verbatim off the decompiled switch's case list at + /// 0x004e9450. + private static readonly IReadOnlySet HeritageTabShowButtonIds = new HashSet + { + 0x100003BFu, 0x100003C1u, 0x100003C2u, 0x100003C3u, + 0x10000590u, 0x10000591u, 0x100005A9u, 0x100005BFu, + 0x100005C4u, 0x100005E8u, + }; + + /// HIDE ids (@0x004e96b9, three SetVisible(0) calls) — + /// the Olthoi/OlthoiAcid heritage buttons. + private static readonly IReadOnlySet HeritageTabHideButtonIds = new HashSet + { + 0x100005C7u, 0x100005C8u, + }; + + /// + /// Ports gmCharGenMainUI::ListenToElementMessage @ 0x004e9450's + /// heritage-button tab-restore arm: heritage-button clicks bubble to + /// the master shell and SYNCHRONOUSLY show/hide the Profession/Skills/ + /// Town tabs, independent of 's own + /// tab-visibility recompute at page-switch time (that recompute only + /// runs when Back/Next/a tab is clicked — not on every heritage pick). + /// Retail quirk reproduced faithfully: Lugian's button id + /// (0x100005f1) sits OUTSIDE both the SHOW and HIDE case lists + /// in the decompiled switch, so clicking Lugian neither restores nor + /// hides the tabs — a genuine retail bug (the tabs stay in whatever + /// state the PREVIOUS heritage selection left them), not an acdream + /// omission. Review fix round F3 (2026-08-15): this arm was entirely + /// unported — before this fix, selecting a human heritage right after + /// Olthoi/OlthoiAcid left the tabs hidden until the next Back/Next/tab + /// click recomputed them. + /// + private void ApplyHeritageTabRestore(uint buttonElementId) + { + if (HeritageTabShowButtonIds.Contains(buttonElementId)) + { + _professionTab.Visible = true; + _skillsTab.Visible = true; + _townTab.Visible = true; + } + else if (HeritageTabHideButtonIds.Contains(buttonElementId)) + { + _professionTab.Visible = false; + _skillsTab.Visible = false; + _townTab.Visible = false; + } + // Else (including Lugian, 0x100005f1): no-op, matching retail. + } + + // ── Finish (gmCharGenMainUI::DoFinish @ 0x004E9170) ───────────────── + + /// The Finish button's ordinary click — retail's arg2 = 1 + /// call site (0x004E9579). Re-checks the current page defensively, + /// mirroring ListenToElementMessage's own + /// m_eProgressState != ECG_SUMMARY no-op guard. + private void OnFinish() + { + if (_disposed || _currentPage != Page.Summary) + return; + TryFinish(confirmedUnspentCredits: false); + } + + /// + /// Sends via + /// (which itself calls RuntimeCharacterCreationState.TryBeginFinish); + /// on a LOCAL refusal, surfaces retail's own dialog for the two refusal + /// reasons retail dialogs at all (NoName -> + /// ID_CharGen_NoNameWarning; AttributeCreditsUnspent -> + /// the credit-warning confirm, whose OWN confirm re-invokes this method + /// with — retail's + /// arg2 == 0 call site, 0x004E98BB). The remaining local + /// refusals (HeritageOrGenderUnset, AlreadyPending, + /// RosterFull) have no retail dialog citation — retail's own + /// DoFinish silently falls through to its final return 0 + /// for an already-Pending double-click, and the other two are + /// acdream-only additions (register AP-223, AP-211) with the same + /// silent-refusal shape. + /// + private void TryFinish(bool confirmedUnspentCredits) + { + if (_bindings.Finish(confirmedUnspentCredits).Status != RuntimeCommandStatus.Rejected) + return; + + RuntimeCharacterCreationLocalRefusal refusal = + _bindings.View()?.Snapshot.LastLocalRefusal ?? default; + if (refusal.NoName) + ShowNoNameWarningDialog(); + else if (refusal.AttributeCreditsUnspent) + ShowCreditWarningDialog(); + } + + /// Ports the empty-name half of DoFinish + /// (ID_CharGen_NoNameWarning, @0x004e91dd) — a plain + /// informational dialog, no confirm/cancel semantics. + private void ShowNoNameWarningDialog() + { + if (_noNameWarningDialogContext != 0u) + return; + _noNameWarningDialogContext = _dialogs.MakeMessage( + _strings.NoNameWarning, + data => + { + _ = data; + _noNameWarningDialogContext = 0u; + }); + } + + /// Ports gmCharGenMainUI::MakeCreditWarningDialog @ + /// 0x004e8870 (ID_CharGen_CreditWarning) — on confirm, + /// re-invokes with + /// confirmedUnspentCredits: true, retail's DoFinish(this, 0) + /// call at RecvNotice_CloseDialog @0x004e98bb. + private void ShowCreditWarningDialog() + { + if (_creditWarningDialogContext != 0u) + return; + _creditWarningDialogContext = _dialogs.MakeConfirmation( + _strings.CreditWarning, + data => + { + _creditWarningDialogContext = 0u; + if (_disposed || _suppressDialogCallbacks) + return; + if (data.GetBoolean(RetailDialogProperty.ConfirmationResult)) + TryFinish(confirmedUnspentCredits: true); + }); + } + + // ── 0xF643 rejection dialogs (Handle_CharGenVerificationResponse @ ── + // ── 0x0055E8B0) ────────────────────────────────────────────────────── + + /// + /// Ports the COMPLETE rejection-dialog mapping from + /// gmCharGenMainUI::RecvNotice_CharGenVerificationResponse @ + /// 0x004e9030's own switch + its (arg2-1) > 6 + /// unsigned-underflow default arm (restated on + /// 's own doc comment). + /// CC5 review-fix round F2 (2026-08-16): every non-Ok code now reaches + /// this method (RuntimeCharacterCreationState.ApplyCreationResponse + /// no longer special-cases Pending/Undef as a silent reset) and every + /// branch here resolves to a real dialog — retail's dispatch has NO + /// silent case. Dedups against the LAST rejection instance already + /// shown so a same-value re-check on a later (this + /// method runs every tick, not just on revision change) doesn't reopen + /// the dialog the player already dismissed. + /// + private void ReconcileDialogs(RuntimeCharacterCreationSnapshot snapshot) + { + RuntimeCharacterCreationRejection? rejection = snapshot.LastRejection; + if (rejection is null) + { + _lastShownRejection = null; + return; + } + if (_lastShownRejection == rejection) + return; + _lastShownRejection = rejection; + + // MakeErrorMessageDialog's own guard @0x004e8cc4: a context already + // open is a no-op (the SECOND rejection's dialog is silently + // dropped, not queued) — F4's fix, matching the four sibling + // dialogs' shape. _lastShownRejection is already updated above even + // when this guard blocks the dialog, which is retail-faithful: a + // later Tick with the SAME rejection value must not retry it either + // (this scenario is not reachable through the ordinary UI today — + // TryBeginFinish's AlreadyPending refusal means a second Finish + // cannot land while a rejection is still unacknowledged — but the + // guard exists so the SHAPE matches retail's even if a future + // caller reaches it). + if (_errorMessageDialogContext != 0u) + return; + + // Pending/Corrupt/DatabaseDown are explicit switch cases in retail's + // own dispatch landing on the SAME "ID_Character_Err_NameDBDown" + // label; Undef and any code outside 1..7 fall through that + // function's unsigned-underflow default arm to the identical label + // — the `_` arm below is that default, not a "no dialog" case. + string key = rejection.Value.Code switch + { + CharGenVerificationResponse.Code.NameInUse => "ID_Character_Err_NameReserved", + CharGenVerificationResponse.Code.NameBanned => "ID_Character_Err_NameBanned", + CharGenVerificationResponse.Code.AdminPrivilegeDenied => "ID_Character_Err_NameAdminDenied", + _ => "ID_Character_Err_NameDBDown", + }; + string? message = _bindings.ResolveText?.Invoke(key); + if (message is null) + return; + + _errorMessageDialogContext = _dialogs.MakeMessage(message, data => + { + _errorMessageDialogContext = 0u; + _ = data; + if (_disposed || _suppressDialogCallbacks) + return; + _bindings.AcknowledgeRejection?.Invoke(); + }); + } + + private void Deactivate() + { + if (_active) + { + _active = false; + _openOnStartConsumed = false; + Close(); + } + CloseAllDialogs(suppressCallbacks: true); + } + + private void CloseAllDialogs(bool suppressCallbacks) + { + bool previous = _suppressDialogCallbacks; + _suppressDialogCallbacks |= suppressCallbacks; + try + { + if (_exitDialogContext != 0u) + { + uint closing = _exitDialogContext; + _exitDialogContext = 0u; + _dialogs.CloseDialog(closing); + } + if (_creditWarningDialogContext != 0u) + { + uint closing = _creditWarningDialogContext; + _creditWarningDialogContext = 0u; + _dialogs.CloseDialog(closing); + } + if (_randomizeWarningDialogContext != 0u) + { + uint closing = _randomizeWarningDialogContext; + _randomizeWarningDialogContext = 0u; + _dialogs.CloseDialog(closing); + } + if (_noNameWarningDialogContext != 0u) + { + uint closing = _noNameWarningDialogContext; + _noNameWarningDialogContext = 0u; + _dialogs.CloseDialog(closing); + } + if (_errorMessageDialogContext != 0u) + { + uint closing = _errorMessageDialogContext; + _errorMessageDialogContext = 0u; + _dialogs.CloseDialog(closing); + } + } + finally + { + _suppressDialogCallbacks = previous; + } + } +} diff --git a/src/AcDream.App/UI/Layout/CharacterCreationUiMountCoordinator.cs b/src/AcDream.App/UI/Layout/CharacterCreationUiMountCoordinator.cs new file mode 100644 index 00000000..a6d83b45 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterCreationUiMountCoordinator.cs @@ -0,0 +1,99 @@ +namespace AcDream.App.UI.Layout; + +internal sealed record CharacterCreationUiMountResources( + uint LayoutId, + ImportedLayout Layout, + Func TemplateResolver, + CharacterCreationUiController.DialogStrings Strings); + +/// +/// Retryable, idempotent composition edge for the character-creation screen — +/// clone of 's recipe. DATs +/// can become readable after the graphical runtime starts, so an unavailable +/// dialog catalog, root, or string must not permanently suppress the screen. +/// +internal sealed class CharacterCreationUiMountCoordinator : IDisposable +{ + private readonly UiRoot _host; + private readonly CharacterCreationRuntimeBindings _bindings; + private readonly Func _ensureDialogs; + private readonly Func _loadResources; + private bool _disposed; + + public CharacterCreationUiMountCoordinator( + UiRoot host, + CharacterCreationRuntimeBindings bindings, + Func ensureDialogs, + Func loadResources) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _bindings = bindings ?? throw new ArgumentNullException(nameof(bindings)); + _ensureDialogs = ensureDialogs + ?? throw new ArgumentNullException(nameof(ensureDialogs)); + _loadResources = loadResources + ?? throw new ArgumentNullException(nameof(loadResources)); + } + + public CharacterCreationUiController? Controller { get; private set; } + + public void Tick() + { + if (_disposed || Controller is not null) + return; + + try + { + RetailDialogFactory? dialogs = _ensureDialogs(); + if (dialogs is null) + return; + + CharacterCreationUiMountResources? resources = _loadResources(); + if (resources is null) + return; + + CharacterCreationUiController? candidate = + CharacterCreationUiController.CreateDetached( + _host, + resources.Layout, + resources.TemplateResolver, + dialogs, + _bindings, + resources.Strings); + if (candidate is null) + return; + + Controller = candidate; + candidate.AttachAndTick(); + Console.WriteLine( + $"[UI] retail character creation from enum table 5 " + + $"(0x10000039 -> 0x{resources.LayoutId:X8}, root 0x100003CC)."); + } + catch (Exception error) + { + CharacterCreationUiController? partial = Controller; + Controller = null; + try + { + partial?.Dispose(); + } + catch (Exception cleanupError) + { + Console.WriteLine( + "[UI] character creation partial-mount cleanup failed: " + + cleanupError.Message); + } + Console.WriteLine( + "[UI] character creation mount will retry after resource " + + $"recovery: {error.Message}"); + } + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + Controller?.Dispose(); + Controller = null; + } +} diff --git a/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs new file mode 100644 index 00000000..963d5e23 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterManagementUiController.cs @@ -0,0 +1,828 @@ +using System.Numerics; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.App.UI.Layout; + +/// +/// Projects Runtime's one borrowed pre-world character-selection owner through +/// retail gmCharacterManagementUI's authored retained layout. The list is +/// intentionally flat: the retail class owns no viewport or model preview. +/// +internal sealed class CharacterManagementUiController : IDisposable +{ + internal const uint RootEnum = 0x10000005u; + internal const uint RootElementId = 0x1000039Au; + internal const uint WorldTextElementId = 0x1000039Bu; + internal const uint ListElementId = 0x1000039Du; + internal const uint CreateElementId = 0x100003A0u; + internal const uint EnterElementId = 0x100003A2u; + internal const uint DeleteElementId = 0x1000039Fu; + internal const uint RestoreElementId = 0x1000039Eu; + /// + /// gmCharacterManagementUI::ListenToElementMessage@0x004ed5a0's element-id + /// switch is keyed off idElement - 0x1000039d (the listbox base); + /// offset 6 -> QueueUIMode(0x10000005), the mode gmCreditsUI registers + /// (Register@0x0047a69e) — out of scope this round (finding 1 note). + /// + internal const uint CreditsElementId = 0x100003A3u; + /// Offset 7 from the listbox base -> MakeConfirmExitDialog@0x004ed250. + internal const uint ExitElementId = 0x100003A4u; + + internal sealed record DialogStrings( + Func DeleteConfirmation, + string DeleteResponse, + string PleaseWait, + string EnteringWorld, + /// + /// Retail ID_CharacterManagement_ConfirmExit (table + /// 0x23000002) — "Are you sure you want to leave?", the text + /// MakeConfirmExitDialog@0x004ed250 resolves via + /// StringInfo::SetStringIDandTableEnum(compute_str_hash( + /// "ID_CharacterManagement_ConfirmExit"), 0x10000002). + /// + string ConfirmExit); + + private readonly UiRoot _host; + private readonly ImportedLayout _layout; + private readonly UiText _worldText; + private readonly UiTemplateListBox _list; + private readonly UiButton _create; + private readonly UiButton _enter; + private readonly UiButton _delete; + private readonly UiButton _restore; + private readonly UiButton _credits; + private readonly UiButton _exit; + private readonly RetailDialogFactory _dialogs; + private readonly CharacterSelectionRuntimeBindings _bindings; + private readonly DialogStrings _strings; + private readonly List _rows = []; + private readonly Dictionary _rowIds = []; + + private Vector2 _authoredCanvas; + private RuntimeGenerationToken _lastGeneration; + private long _lastRevision = long.MinValue; + private string _lastWorldName = string.Empty; + private uint _deleteDialogContext; + private uint _operationWaitContext; + private uint _enterWaitContext; + private uint _errorDialogContext; + private uint _confirmExitDialogContext; + private bool _active; + private bool _restoreCommandInFlight; + private bool _suppressDialogCallbacks; + private bool _disposed; + + private CharacterManagementUiController( + UiRoot host, + ImportedLayout layout, + UiText worldText, + UiTemplateListBox list, + UiButton create, + UiButton enter, + UiButton delete, + UiButton restore, + UiButton credits, + UiButton exit, + RetailDialogFactory dialogs, + CharacterSelectionRuntimeBindings bindings, + DialogStrings strings) + { + _host = host; + _layout = layout; + _worldText = worldText; + _list = list; + _create = create; + _enter = enter; + _delete = delete; + _restore = restore; + _credits = credits; + _exit = exit; + _dialogs = dialogs; + _bindings = bindings; + _strings = strings; + + Root.Left = 0f; + Root.Top = 0f; + Root.ClickThrough = false; + Root.Visible = false; + + // Campaign LA gate round 2 (register AD-98): the root KEEPS its authored + // 800×600 extent — retail never resizes it (zero edge anchors, verified + // against the installed DAT) and its blitter has no stretch mode; the + // whole composed screen stretches once at presentation. Our equivalent: + // while this screen is active, the host stretches the ENTIRE canvas — + // widgets, glyphs, and the painted background (which carries the + // "World"/"Characters" captions as art) — as one unit via + // UiRoot.FixedCanvasSize, declared/revoked through the owner-scoped + // arbiter (review fix round R1, 2026-08-15) rather than written + // directly — character-creation can be simultaneously active on top + // of this screen, and a raw write from either controller is a last- + // writer-wins race with no owner. Resizing the root here instead of + // using the canvas is exactly the half-substitution that misaligned + // the widgets against the stretched art at the 2026-08-15 user gate. + _authoredCanvas = new Vector2( + Root.Width > 0f ? Root.Width : 800f, + Root.Height > 0f ? Root.Height : 600f); + + // Campaign CC slice CC7: gmCharacterManagementUI::ListenToElementMessage + // @ 0x004ed5a0 case 3 dispatches Create unconditionally on click + // (QueueUIMode(0x1000000b) — no gate at click time); the gate lives + // entirely in UpdateButtons @ 0x004ec240's own Enabled/ghosted state + // (see ApplyButtons below), so the click handler is wired once here + // and Enabled tracks the borrowed snapshot every tick. Starts + // disabled/ghosted until the first real snapshot arrives. + _create.Visible = true; + _create.Enabled = false; + _create.OnClick = RequestCreate; + _enter.OnClick = EnterSelected; + _delete.OnClick = RequestDelete; + _restore.OnClick = RestoreSelected; + + // Credits (retail QueueUIMode(0x10000005) -> gmCreditsUI) is out of + // scope this round (finding 1 note) — same "future campaign, visibly + // ghosted, no invented action" treatment as Create above. Filed as + // issue #400. + _credits.Visible = true; + _credits.Enabled = false; + _credits.OnClick = null; + _exit.OnClick = RequestExit; + + // World name (retail UpdateWorldName@0x004ec120 / + // RecvNotice_WorldName@0x004ec360 both just push + // Client::GetWorldName() onto this element). LinesProvider reads the + // live field Tick() updates each time Runtime's snapshot changes. + _worldText.LinesProvider = + () => [new UiText.Line(_lastWorldName, _worldText.DefaultColor)]; + } + + internal UiElement Root => _layout.Root; + internal IReadOnlyList Rows => _rows; + internal uint DeleteDialogContext => _deleteDialogContext; + internal uint OperationWaitContext => _operationWaitContext; + internal uint EnterWaitContext => _enterWaitContext; + internal uint ErrorDialogContext => _errorDialogContext; + internal uint ConfirmExitDialogContext => _confirmExitDialogContext; + + internal void ResetSession() + { + if (_disposed) + return; + Deactivate(); + _lastRevision = long.MinValue; + } + + internal static CharacterManagementUiController? Bind( + UiRoot host, + ImportedLayout layout, + Func templateResolver, + RetailDialogFactory dialogs, + CharacterSelectionRuntimeBindings bindings, + DialogStrings strings) + { + CharacterManagementUiController? controller = CreateDetached( + host, + layout, + templateResolver, + dialogs, + bindings, + strings); + if (controller is null) + return null; + + try + { + controller.AttachAndTick(); + return controller; + } + catch + { + controller.Dispose(); + throw; + } + } + + internal static CharacterManagementUiController? CreateDetached( + UiRoot host, + ImportedLayout layout, + Func templateResolver, + RetailDialogFactory dialogs, + CharacterSelectionRuntimeBindings bindings, + DialogStrings strings) + { + ArgumentNullException.ThrowIfNull(host); + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(templateResolver); + ArgumentNullException.ThrowIfNull(dialogs); + ArgumentNullException.ThrowIfNull(bindings); + ArgumentNullException.ThrowIfNull(strings); + + if (ContainsViewport(layout.Root)) + { + Console.WriteLine( + "[UI] character management: refusing an unapproved model-preview viewport."); + return null; + } + + if (layout.Root.DatElementId != RootElementId + || layout.FindElement(WorldTextElementId) is not UiText worldText + || layout.FindElement(ListElementId) is not UiTemplateListBox list + || layout.FindElement(CreateElementId) is not UiButton create + || layout.FindElement(EnterElementId) is not UiButton enter + || layout.FindElement(DeleteElementId) is not UiButton delete + || layout.FindElement(RestoreElementId) is not UiButton restore + || layout.FindElement(CreditsElementId) is not UiButton credits + || layout.FindElement(ExitElementId) is not UiButton exit) + { + Console.WriteLine( + "[UI] character management: the authored root/list/button contract is incomplete."); + return null; + } + + list.TemplateResolver = templateResolver; + try + { + return new CharacterManagementUiController( + host, + layout, + worldText, + list, + create, + enter, + delete, + restore, + credits, + exit, + dialogs, + bindings, + strings); + } + catch + { + list.TemplateResolver = null; + create.OnClick = null; + enter.OnClick = null; + delete.OnClick = null; + restore.OnClick = null; + exit.OnClick = null; + throw; + } + } + + internal void AttachAndTick() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (Root.Parent is null) + _host.AddChild(Root); + Tick(); + } + + private static bool ContainsViewport(UiElement element) + { + if (element is UiViewport) + return true; + foreach (UiElement child in element.Children) + if (ContainsViewport(child)) + return true; + return false; + } + + internal void Tick() + { + if (_disposed) + return; + + IRuntimeCharacterSelectionView? view = _bindings.View(); + RuntimeCharacterSelectionSnapshot snapshot = view?.Snapshot ?? default; + if (view is null || !snapshot.IsActive) + { + Deactivate(); + _lastGeneration = snapshot.Generation; + _lastRevision = snapshot.Revision; + return; + } + + if (!_active) + { + _active = true; + Root.Visible = true; + _host.DeclareFixedCanvas(this, _authoredCanvas); + _host.BringToFront(Root); + } + + // World name rides independently of the roster revision gate below — + // ServerName can arrive slightly before or after CharacterList (see + // RuntimeCharacterSelectionState.ApplyWorldName). + _lastWorldName = snapshot.WorldName; + + if (_lastGeneration != snapshot.Generation + || _lastRevision != snapshot.Revision) + { + if (TryCaptureRoster(view, snapshot, out RuntimeCharacterSelectionEntry[] roster)) + { + bool rowsReady; + if (RowsMatchRoster(roster, snapshot.SlotCount)) + { + ApplyHighlight(snapshot.HighlightedCharacterId); + rowsReady = true; + } + else + { + rowsReady = RebuildRows( + roster, + snapshot.SlotCount, + snapshot.HighlightedCharacterId); + } + + if (rowsReady) + { + _lastGeneration = snapshot.Generation; + _lastRevision = snapshot.Revision; + } + } + else + { + // A receive-thread roster/reset raced the borrowed snapshot. + // Leave the revision unconsumed so the next frame retries from + // one coherent view; never present a partially mixed roster. + _lastRevision = long.MinValue; + snapshot = view.Snapshot; + if (!snapshot.IsActive) + { + Deactivate(); + _lastGeneration = snapshot.Generation; + _lastRevision = snapshot.Revision; + return; + } + } + } + else + { + ApplyHighlight(snapshot.HighlightedCharacterId); + } + + ApplyButtons(snapshot.Buttons); + ReconcileDialogs(view, snapshot); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + try + { + CloseAllDialogs(suppressCallbacks: true); + } + finally + { + _host.RevokeFixedCanvas(this); + _enter.OnClick = null; + _delete.OnClick = null; + _restore.OnClick = null; + _exit.OnClick = null; + foreach (UiButton row in _rows) + { + row.OnClick = null; + row.OnDoubleClick = null; + } + _rows.Clear(); + _rowIds.Clear(); + _list.Flush(); + _list.TemplateResolver = null; + _host.RemoveChild(Root); + } + } + + private static bool TryCaptureRoster( + IRuntimeCharacterSelectionView view, + RuntimeCharacterSelectionSnapshot expected, + out RuntimeCharacterSelectionEntry[] roster) + { + roster = new RuntimeCharacterSelectionEntry[expected.RosterCount]; + for (int i = 0; i < roster.Length; i++) + { + if (!view.TryGetAt(i, out roster[i])) + return false; + } + + RuntimeCharacterSelectionSnapshot after = view.Snapshot; + return after.Generation == expected.Generation + && after.Revision == expected.Revision + && after.RosterCount == expected.RosterCount; + } + + private bool RowsMatchRoster( + IReadOnlyList roster, + int allowedSlotCount) + { + if (_rows.Count != roster.Count) + return false; + + int rowHeight = ComputeRowHeight( + _list.Height, + roster.Count, + allowedSlotCount); + for (int i = 0; i < roster.Count; i++) + { + UiButton row = _rows[i]; + RuntimeCharacterSelectionEntry character = roster[i]; + if (!_rowIds.TryGetValue(row, out uint characterId) + || characterId != character.CharacterId + || !string.Equals(row.Label, character.Name, StringComparison.Ordinal) + || (int)row.Height != rowHeight + || row.LabelColor != (character.IsPendingDelete + ? new Vector4(1f, 0f, 0f, 1f) + : Vector4.One)) + { + return false; + } + } + + return true; + } + + private bool RebuildRows( + IReadOnlyList roster, + int allowedSlotCount, + uint highlightedCharacterId) + { + foreach (UiButton row in _rows) + { + row.OnClick = null; + row.OnDoubleClick = null; + } + _rows.Clear(); + _rowIds.Clear(); + _list.Flush(); + + int rowHeight = ComputeRowHeight( + _list.Height, + roster.Count, + allowedSlotCount); + _list.LineHeight = rowHeight; + bool complete = _list.Templates.Count > 0 + && _list.TemplateResolver is not null; + foreach (RuntimeCharacterSelectionEntry character in roster) + { + if (!complete) + break; + + UiTemplateListEntry template = _list.Templates[0]; + if (_list.TemplateResolver!( + template.TemplateLayoutId, + template.TemplateElementId) is not UiButton row) + { + complete = false; + break; + } + + // AddItemFromTemplateList creates the same template, but its + // retained viewport stacks at the template's authored 16px + // height. Retail establishes the computed size on every row; our + // list fixes Top during insertion, so build and resize first to + // make every subsequent Top exact. + row.Height = rowHeight; + _list.AddPrebuiltRow(row); + uint characterId = character.CharacterId; + row.Label = character.Name; + row.LabelColor = character.IsPendingDelete + ? new Vector4(1f, 0f, 0f, 1f) + : Vector4.One; + row.Enabled = true; + row.SuppressSelfToggle = true; + row.Selected = characterId == highlightedCharacterId; + row.OnClick = () => Highlight(characterId); + row.OnDoubleClick = EnterSelected; + _rows.Add(row); + _rowIds.Add(row, characterId); + } + + if (complete) + return true; + + foreach (UiButton row in _rows) + { + row.OnClick = null; + row.OnDoubleClick = null; + } + _rows.Clear(); + _rowIds.Clear(); + _list.Flush(); + _lastRevision = long.MinValue; + return false; + } + + internal static int ComputeRowHeight( + float listHeight, + int rosterCount, + int allowedSlotCount) + { + // RebuildCharacterList @ 0x004EC3A0 uses integer UIRegion height and + // signed integer division for both terms. The 0x66666667 multiply/ + // shift sequence is compiler output for height / 10. + int height = (int)MathF.Truncate(listHeight); + int denominator = Math.Max(rosterCount, allowedSlotCount); + if (denominator <= 0) + return height / 10; + return Math.Max(height / denominator, height / 10); + } + + private void ApplyHighlight(uint highlightedCharacterId) + { + foreach (UiButton row in _rows) + row.Selected = _rowIds.TryGetValue(row, out uint characterId) + && characterId == highlightedCharacterId; + } + + private void ApplyButtons(RuntimeCharacterSelectionButtons buttons) + { + _create.Visible = true; + _create.Enabled = buttons.CanCreate; + _enter.Enabled = buttons.CanEnter; + _delete.Visible = buttons.DeleteVisible; + _delete.Enabled = buttons.CanDelete; + _restore.Visible = buttons.RestoreVisible; + _restore.Enabled = buttons.CanRestore; + } + + private void Highlight(uint characterId) + { + if (_disposed) + return; + _bindings.Highlight(characterId); + InvalidateAndTick(); + } + + /// + /// Campaign CC slice CC7: ListenToElementMessage case 3 -> + /// QueueUIMode(0x1000000b). Purely presentational — no Runtime + /// command, no roster/state change here; ' + /// RequestCreate is resolved per-call (never captured) so it + /// reflects whatever wired at the time of + /// the click, matching every other late-bound seam in this bindings + /// record. + /// + private void RequestCreate() + { + if (_disposed) + return; + _bindings.RequestCreate?.Invoke(); + } + + private void EnterSelected() + { + if (_disposed) + return; + + // Open retail's wait context before the synchronous Runtime command + // starts its existing ServerReady transaction. The state projection + // remains authoritative and closes it on InWorld/error/reset. + EnsureEnterWait(); + RuntimeCommandResult result = _bindings.Enter(); + if (!result.Accepted) + CloseContext(ref _enterWaitContext, suppressCallback: true); + InvalidateAndTick(); + } + + private void RequestDelete() + { + if (_disposed) + return; + _bindings.RequestDelete(); + InvalidateAndTick(); + } + + private void RestoreSelected() + { + if (_disposed) + return; + + // ListenToElementMessage @ 0x004ED5A0 opens Please Wait before it + // calls CPlayerSystem::RestoreCharacter. Keep it modal even if a + // synchronous command callback re-enters Tick before Runtime has + // returned its accepted projection. + EnsureOperationWait(); + RuntimeCommandResult result = default; + Exception? failure = null; + _restoreCommandInFlight = true; + try + { + result = _bindings.Restore(); + } + catch (Exception error) + { + failure = error; + } + finally + { + _restoreCommandInFlight = false; + } + + if (failure is not null) + { + Console.WriteLine( + $"[UI] character restore command failed: {failure.Message}"); + CloseContext(ref _operationWaitContext, suppressCallback: true); + InvalidateAndTick(); + return; + } + + if (!result.Accepted) + CloseContext(ref _operationWaitContext, suppressCallback: true); + InvalidateAndTick(); + } + + private void RequestExit() + { + if (_disposed) + return; + + // MakeConfirmExitDialog @ 0x004ed250's own guard: a second Exit + // click while the confirmation is already open is a no-op. + if (_confirmExitDialogContext != 0u) + return; + + _confirmExitDialogContext = _dialogs.MakeConfirmation( + _strings.ConfirmExit, + data => + { + _confirmExitDialogContext = 0u; + if (_disposed || _suppressDialogCallbacks) + return; + + // RecvNotice_CloseDialog @ 0x004ed760 case 1: only a + // confirmed (OK) close proceeds through the SAME graceful + // shutdown path window-close uses; Cancel leaves the screen + // exactly as it was. + if (data.GetBoolean(RetailDialogProperty.ConfirmationResult)) + _bindings.RequestExit(); + }); + } + + private void ReconcileDialogs( + IRuntimeCharacterSelectionView view, + RuntimeCharacterSelectionSnapshot snapshot) + { + if (snapshot.Error is { } error) + { + CloseContext(ref _deleteDialogContext, suppressCallback: true); + CloseContext(ref _operationWaitContext, suppressCallback: true); + CloseContext(ref _enterWaitContext, suppressCallback: true); + EnsureError(error.Message); + return; + } + + CloseContext(ref _errorDialogContext, suppressCallback: true); + if (snapshot.Lifecycle == RuntimeCharacterSelectionLifecycle.EnteringWorld) + { + CloseContext(ref _deleteDialogContext, suppressCallback: true); + CloseContext(ref _operationWaitContext, suppressCallback: true); + EnsureEnterWait(); + return; + } + + CloseContext(ref _enterWaitContext, suppressCallback: true); + if (snapshot.PendingDeleteCharacterId != 0u + && view.TryGet(snapshot.PendingDeleteCharacterId, out RuntimeCharacterSelectionEntry pending)) + { + EnsureDeleteConfirmation(pending.Name); + } + else + { + CloseContext(ref _deleteDialogContext, suppressCallback: true); + } + + if (_restoreCommandInFlight + || snapshot.Operation is RuntimeCharacterSelectionOperation.DeleteRequested + or RuntimeCharacterSelectionOperation.DeleteAcknowledged + or RuntimeCharacterSelectionOperation.RestoreRequested) + { + EnsureOperationWait(); + } + else + { + CloseContext(ref _operationWaitContext, suppressCallback: true); + } + } + + private void EnsureDeleteConfirmation(string characterName) + { + if (_deleteDialogContext != 0u) + return; + + _deleteDialogContext = _dialogs.MakeConfirmationTextInput( + _strings.DeleteConfirmation(characterName), + data => + { + _deleteDialogContext = 0u; + if (_disposed || _suppressDialogCallbacks) + return; + + string response = data.GetString( + RetailDialogProperty.TextInputResult) ?? string.Empty; + if (string.Equals( + response, + _strings.DeleteResponse, + StringComparison.OrdinalIgnoreCase)) + { + _bindings.ConfirmDelete(); + } + else + { + _bindings.Cancel(); + } + InvalidateAndTick(); + }); + } + + private void EnsureOperationWait() + { + if (_operationWaitContext == 0u) + _operationWaitContext = _dialogs.MakeWait(_strings.PleaseWait); + } + + private void EnsureEnterWait() + { + if (_enterWaitContext == 0u) + _enterWaitContext = _dialogs.MakeWait(_strings.EnteringWorld); + } + + private void EnsureError(string message) + { + if (_errorDialogContext != 0u) + return; + _errorDialogContext = _dialogs.MakeMessage( + message, + _ => + { + _errorDialogContext = 0u; + if (_disposed || _suppressDialogCallbacks) + return; + _bindings.Cancel(); + InvalidateAndTick(); + }); + } + + private void InvalidateAndTick() + { + _lastRevision = long.MinValue; + Tick(); + } + + private void Deactivate() + { + if (_active) + { + _active = false; + Root.Visible = false; + _host.RevokeFixedCanvas(this); + } + foreach (UiButton row in _rows) + { + row.OnClick = null; + row.OnDoubleClick = null; + } + _rows.Clear(); + _rowIds.Clear(); + _list.Flush(); + CloseAllDialogs(suppressCallbacks: true); + } + + private void CloseAllDialogs(bool suppressCallbacks) + { + bool previous = _suppressDialogCallbacks; + _suppressDialogCallbacks |= suppressCallbacks; + try + { + CloseContext(ref _deleteDialogContext, suppressCallback: false); + CloseContext(ref _operationWaitContext, suppressCallback: false); + CloseContext(ref _enterWaitContext, suppressCallback: false); + CloseContext(ref _errorDialogContext, suppressCallback: false); + CloseContext(ref _confirmExitDialogContext, suppressCallback: false); + } + finally + { + _suppressDialogCallbacks = previous; + } + } + + private void CloseContext(ref uint context, bool suppressCallback) + { + uint closing = context; + if (closing == 0u) + return; + context = 0u; + + bool previous = _suppressDialogCallbacks; + _suppressDialogCallbacks |= suppressCallback; + try + { + _dialogs.CloseDialog(closing); + } + finally + { + _suppressDialogCallbacks = previous; + } + } +} diff --git a/src/AcDream.App/UI/Layout/CharacterManagementUiMountCoordinator.cs b/src/AcDream.App/UI/Layout/CharacterManagementUiMountCoordinator.cs new file mode 100644 index 00000000..e2748193 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterManagementUiMountCoordinator.cs @@ -0,0 +1,106 @@ +namespace AcDream.App.UI.Layout; + +internal sealed record CharacterManagementUiMountResources( + uint LayoutId, + ImportedLayout Layout, + Func TemplateResolver, + CharacterManagementUiController.DialogStrings Strings); + +/// +/// Retryable, idempotent composition edge for the pre-world character screen. +/// DATs can become readable after the graphical runtime starts (installer copy, +/// mapped-file replacement, or a transient catalog miss), so an unavailable +/// dialog catalog, root, template, or string must not permanently suppress the +/// screen. Once bound, later ticks are no-ops and cannot duplicate the root or +/// controller lifetime. +/// +internal sealed class CharacterManagementUiMountCoordinator : IDisposable +{ + private readonly UiRoot _host; + private readonly CharacterSelectionRuntimeBindings _bindings; + private readonly Func _ensureDialogs; + private readonly Func _loadResources; + private bool _disposed; + + public CharacterManagementUiMountCoordinator( + UiRoot host, + CharacterSelectionRuntimeBindings bindings, + Func ensureDialogs, + Func loadResources) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _bindings = bindings ?? throw new ArgumentNullException(nameof(bindings)); + _ensureDialogs = ensureDialogs + ?? throw new ArgumentNullException(nameof(ensureDialogs)); + _loadResources = loadResources + ?? throw new ArgumentNullException(nameof(loadResources)); + } + + public CharacterManagementUiController? Controller { get; private set; } + + public void Tick() + { + if (_disposed || Controller is not null) + return; + + try + { + RetailDialogFactory? dialogs = _ensureDialogs(); + if (dialogs is null) + return; + + CharacterManagementUiMountResources? resources = _loadResources(); + if (resources is null) + return; + + CharacterManagementUiController? candidate = + CharacterManagementUiController.CreateDetached( + _host, + resources.Layout, + resources.TemplateResolver, + dialogs, + _bindings, + resources.Strings); + if (candidate is null) + return; + + // Take ownership before the first attach/tick. Template resolution + // happens inside that tick and can throw after the root and button + // handlers are live; the catch below can therefore always retire + // the exact partial controller before a later retry. + Controller = candidate; + candidate.AttachAndTick(); + Console.WriteLine( + $"[UI] retail character management from enum table 5 " + + $"(0x10000005 -> 0x{resources.LayoutId:X8}, " + + "root 0x1000039A; flat list, no viewport)."); + } + catch (Exception error) + { + CharacterManagementUiController? partial = Controller; + Controller = null; + try + { + partial?.Dispose(); + } + catch (Exception cleanupError) + { + Console.WriteLine( + "[UI] character management partial-mount cleanup failed: " + + cleanupError.Message); + } + Console.WriteLine( + "[UI] character management mount will retry after resource " + + $"recovery: {error.Message}"); + } + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + Controller?.Dispose(); + Controller = null; + } +} diff --git a/src/AcDream.App/UI/Layout/ChargenColorSpotComposer.cs b/src/AcDream.App/UI/Layout/ChargenColorSpotComposer.cs new file mode 100644 index 00000000..08aef07c --- /dev/null +++ b/src/AcDream.App/UI/Layout/ChargenColorSpotComposer.cs @@ -0,0 +1,206 @@ +using System.Collections.Generic; +using AcDream.App.Rendering; +using AcDream.Content; +using AcDream.Core.CharGen; +using AcDream.Core.Textures; +using DatReaderWriter; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.UI.Layout; + +/// +/// R3-5/R3-6 (Campaign CC gate round 1 re-test 2) seam: the pre-baked +/// textures 's color-wheel +/// swatches and gradient disc draw instead of a plain multiply-Tint +/// over the authored sprite. See 's +/// own doc for why a plain multiply is wrong here (it cannot recolor a +/// BLACK placeholder region at all, and it corrupts the ring border's own +/// colors). +/// +internal interface IChargenSwatchTextureSource +{ + /// + /// Retail's "blank"/blocked swatch art (enum 0x1000000f, + /// category 7 — gmCGAppearancePage::DoColorSpots @0x0047d850's + /// i >= count branch) — shown UNTINTED for a swatch beyond the + /// current part's real color count (retail's own + /// pColor->SetVisible(1) is unconditional for all 9 swatches; + /// only the CONTENT differs). 0 if unresolved. + /// + uint BlankSpotTexture { get; } + + /// + /// Retail's gradient-disc art (enum 0x1000000e, category 7) — + /// shown MULTIPLY-tinted by the currently selected swatch's own color, + /// matching retail's own SurfaceWindow::BlitAndColor(..., + /// Blit_Multiply, color) (DoGradDisk @0x0047da90's non-Eyes + /// branch) — a genuine multiply, unlike the swatch spots. 0 if + /// unresolved. + /// + uint GradDiskTexture { get; } + + /// + /// Retail's Eyes "grad plug" icon art (enum 0x10000010, category + /// 7) — shown UNTINTED (DoGradDisk's Eyes branch is a plain + /// Blit_Normal, no color argument at all). 0 if unresolved. + /// + uint GradPlugTexture { get; } + + /// + /// Bakes (or returns a cached) recolored copy of the ACTIVE swatch spot + /// template (enum 0x1000000d, category 7) with every EXACT-black + /// pixel replaced by 's own bytes (alpha and every + /// non-black pixel — the ring border — left untouched), matching retail's + /// SurfaceWindow::ReplaceColor call against old-color + /// (0,0,0,1). 0 if the template is unresolved. + /// + uint GetActiveSpotTexture(ChargenSwatchRgb rgb); +} + +/// +/// Live-DAT implementation of . +/// Decodes each of the four DoColorSpots/DoGradDisk category-7 +/// RenderSurfaces ONCE (live-DAT-measured: spot/blank are 37x44, gradDisk/ +/// gradPlug are 110x112 — exactly matching the swatch buttons' and grad +/// circle's own authored rects), uploads the three static ones (blank/ +/// gradDisk/gradPlug) once, and bakes+caches one recolored spot texture per +/// distinct value on demand — mirroring the +/// SAME "decode once, composite/recolor per key, upload, cache" shape +/// already established for item +/// icons and spell components (that class's own +/// GetSpellComponentIcon ports the identical exact-color-match +/// replace this class uses, just matching white instead of black). +/// +internal sealed class ChargenColorSpotComposer : IChargenSwatchTextureSource +{ + private const uint SpotEnumId = 0x1000000Du; + private const uint BlankEnumId = 0x1000000Fu; + private const uint GradDiskEnumId = 0x1000000Eu; + private const uint GradPlugEnumId = 0x10000010u; + private const uint EnumCategory = 7u; + + private readonly IDatReaderWriter _dats; + private readonly TextureCache _cache; + + private DecodedTexture? _spotTemplate; + private bool _spotResolveTried; + private readonly Dictionary<(byte R, byte G, byte B), uint> _bakedSpotByColor = new(); + + private uint _blankTexture; + private bool _blankResolveTried; + private uint _gradDiskTexture; + private bool _gradDiskResolveTried; + private uint _gradPlugTexture; + private bool _gradPlugResolveTried; + + public ChargenColorSpotComposer(IDatReaderWriter dats, TextureCache cache) + { + _dats = dats; + _cache = cache; + } + + public uint BlankSpotTexture + { + get + { + if (!_blankResolveTried) + { + _blankResolveTried = true; + if (TryDecode(BlankEnumId, out DecodedTexture decoded)) + _blankTexture = _cache.UploadRgba8(decoded.Rgba8, decoded.Width, decoded.Height, nearest: true); + } + return _blankTexture; + } + } + + public uint GradDiskTexture + { + get + { + if (!_gradDiskResolveTried) + { + _gradDiskResolveTried = true; + if (TryDecode(GradDiskEnumId, out DecodedTexture decoded)) + _gradDiskTexture = _cache.UploadRgba8(decoded.Rgba8, decoded.Width, decoded.Height, nearest: true); + } + return _gradDiskTexture; + } + } + + public uint GradPlugTexture + { + get + { + if (!_gradPlugResolveTried) + { + _gradPlugResolveTried = true; + if (TryDecode(GradPlugEnumId, out DecodedTexture decoded)) + _gradPlugTexture = _cache.UploadRgba8(decoded.Rgba8, decoded.Width, decoded.Height, nearest: true); + } + return _gradPlugTexture; + } + } + + public uint GetActiveSpotTexture(ChargenSwatchRgb rgb) + { + if (!_spotResolveTried) + { + _spotResolveTried = true; + if (TryDecode(SpotEnumId, out DecodedTexture decoded)) + _spotTemplate = decoded; + } + if (_spotTemplate is not { } template) + return 0u; + + var key = (rgb.R, rgb.G, rgb.B); + if (_bakedSpotByColor.TryGetValue(key, out uint cached)) + return cached; + + byte[] baked = ReplaceExactBlackWithColor(template.Rgba8, rgb); + uint texture = _cache.UploadRgba8(baked, template.Width, template.Height, nearest: true); + _bakedSpotByColor[key] = texture; + return texture; + } + + /// + /// Pure byte-level half of — cloned, + /// GL-free, and unit-testable without a TextureCache. Retail's + /// own old-color argument to SurfaceWindow::ReplaceColor is + /// RGBAColor(0,0,0,1) — opaque black, ALL four channels, not + /// just RGB (the decompiled float quad's own alpha term is + /// 0x3f800000 = 1.0) — so a genuinely transparent padding pixel + /// (alpha 0, also RGB-zero in this port's own decoded padding) does + /// NOT match and is left untouched, exactly like the ring border. + /// Every matched pixel's RGB becomes 's own + /// bytes and alpha is forced to fully opaque (retail's own new-color + /// argument is ALSO alpha 1 — SetColor's computed swatch color + /// carries a hardcoded opaque alpha, not the source pixel's). Mirrors + /// 's + /// own exact-match convention (there, pure white) rather than an + /// invented fuzzy tolerance. + /// + internal static byte[] ReplaceExactBlackWithColor(byte[] rgba, ChargenSwatchRgb rgb) + { + byte[] baked = (byte[])rgba.Clone(); + for (int i = 0; i + 3 < baked.Length; i += 4) + { + if (baked[i] != 0 || baked[i + 1] != 0 || baked[i + 2] != 0 || baked[i + 3] != 255) + continue; + baked[i] = rgb.R; + baked[i + 1] = rgb.G; + baked[i + 2] = rgb.B; + baked[i + 3] = 255; + } + return baked; + } + + private bool TryDecode(uint enumId, out DecodedTexture decoded) + { + decoded = null!; + uint did = RetailDataIdResolver.Resolve(_dats, enumId, EnumCategory); + if (did == 0) return false; + if (!_dats.TryGet(did, out var rs) || rs is null) return false; + decoded = SurfaceDecoder.DecodeRenderSurface(rs); + return true; + } +} diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index 244258ea..182f5f8b 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -207,7 +207,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta /// Widget tree from . /// Chat view-model (transcript data + command routing). /// Factory that returns the live command bus at submit time. - /// Called on every chat submit so it resolves + /// Called on every chat submit so it resolves /// even when the live session is established AFTER runs /// (mirrors the ImGui ChatPanel which re-reads the bus each frame). /// Runtime's canonical per-window filter/open state diff --git a/src/AcDream.App/UI/Layout/DatRichText.cs b/src/AcDream.App/UI/Layout/DatRichText.cs new file mode 100644 index 00000000..60b10bc3 --- /dev/null +++ b/src/AcDream.App/UI/Layout/DatRichText.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.App.UI.Layout; + +/// +/// Shared multi-segment rich-text composer for the chargen description +/// boxes (Campaign CC gate round 1 Batch C — GF-2/GF-3/GF-11a, and the +/// Summary how-to text). Ports retail's +/// UIElement_Text::SetStringInfoWithFont / +/// AppendStringInfoWithFont @ 0x00469D70 composition model: a text +/// box is built from an ORDERED list of string segments, each carrying its +/// OWN font-color palette index +/// (UIElement_Text::AppendStringInfoWithFont's +/// SetFontColorHelper -> InqProperty(0x1B) array lookup — +/// see ). +/// +/// +/// The description pages used to bypass this entirely: they assigned a raw +/// LinesProvider lambda returning ONE unwrapped +/// per composed string, with no escape-normalize and no word-wrap. Two +/// concrete symptoms this caused: literal two-character "\n" +/// escapes rendered as backslash-n instead of a real line break (the DAT +/// stores that literal escape — DatWidgetFactory.BuildText's own +/// authored-string path already normalizes it for single-element authored +/// captions; this helper reproduces the SAME normalize for +/// runtime-composed multi-segment text), and — for the Town page +/// specifically — an unwrapped single line meant the town-specific SUFFIX +/// of the composed string rendered far outside the box's clipped viewport, +/// so switching towns looked like "the text never changes" even though the +/// underlying string genuinely did (only its INVISIBLE tail differed). +/// +/// +internal static class DatRichText +{ + /// One composed segment: text plus the color it should render + /// in. A null or empty is silently skipped (mirrors + /// retail's own null-string-info no-op guards throughout this text + /// composition family). + public readonly record struct Segment(string? Text, Vector4 Color); + + /// + /// Escape-normalizes and word-wraps every segment (independently, so + /// each segment's wrapped lines keep ITS OWN color), then concatenates + /// the results in order. No separator is inserted between segments — + /// retail's own composition calls concatenate directly + /// (AppendStringInfoWithFont/append_n_chars with no + /// interposed literal), so any blank-line spacing between sections + /// comes from the authored DAT string content itself, not from code + /// here. + /// + public static IReadOnlyList Compose( + UiText target, + IReadOnlyList segments) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(segments); + + var lines = new List(); + // R2-1 (Campaign CC gate round 1 Batch E): the wrap width must shrink + // by the SAME left+right inset the draw path now applies (Padding + // plus the four retail margins, UiText.MarginLeft's own doc) — the + // Batch-C regression's second half: text wasn't just drawing at the + // wrong X, it was also wrapping to the FULL box width instead of the + // authored interior width, overflowing the visible right edge too. + float maximumWidth = MathF.Max( + 1f, + target.Width - (target.Padding + target.MarginLeft) - (target.Padding + target.MarginRight)); + Func measure = target.DatFont is { } font + ? font.MeasureWidth + : static value => value.Length * 8f; + + foreach (Segment segment in segments) + { + if (string.IsNullOrEmpty(segment.Text)) + continue; + + // The installed DAT stores the LITERAL two-character escape + // "\n" (0x5C 0x6E), not a real line break — same normalize + // DatWidgetFactory.BuildText's authored-string path already + // applies for single-element authored captions. + string normalized = segment.Text + .Replace("\\n", "\n") + .Replace("\r", string.Empty); + + foreach (string wrapped in UiText.WrapWords(normalized, measure, maximumWidth)) + lines.Add(new UiText.Line(wrapped, segment.Color)); + } + + return lines; + } + + /// + /// Resolves 's own authored font-color + /// palette (dat property 0x1B) entry at , + /// falling back to when the palette is + /// absent or too short. Mirrors the same fallback shape + /// CharacterStatController.BuildSelectedTitleRuns already uses + /// for its own palette-indexed colors. + /// + public static Vector4 PaletteColor(UiText target, int index, Vector4 fallback) => + index >= 0 && index < target.FontColorPalette.Count + ? target.FontColorPalette[index] + : fallback; +} diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 01804e44..c2f939fd 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -122,6 +122,8 @@ public static class DatWidgetFactory 11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137) 12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text 0x13 => new UiDialogRoot(), // ConfirmationDialog + 0x15 => new UiDialogRoot(), // ConfirmationTextInputDialog + 0x17 => new UiDialogRoot(), // MessageDialog 0x19 => new UiDialogRoot(), // WaitDialog (catalog root 0x31 — OP8 #396) 0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots 0x10000035u => BuildCheckbox( @@ -313,6 +315,31 @@ public static class DatWidgetFactory if (slices.Length > 0) bar.ThumbTopSprite = DefaultImage(slices[0]); if (slices.Length > 1) bar.ThumbSprite = DefaultImage(slices[1]); if (slices.Length > 2) bar.ThumbBotSprite = DefaultImage(slices[^1]); + + // R3-4/R3-7 (Campaign CC gate round 1 re-test 2): retail authors + // TWO distinct thumb shapes for UIElement_Scrollbar (Type 11) — + // chat's own scrollbar (0x10000012) is the 3-slice composite the + // block above was built against (the thumb CHILD carries no media + // of its own; three Type-3 grandchildren supply the top-cap/ + // middle/bottom-cap sprites) — but the chargen Skills listbox + // (0x100003f8), Summary's OVERVIEW listbox (0x10000401), and the + // Summary how-to box (0x100002e7 under 0x10000404) all author a + // SIMPLE single-sprite thumb instead: the SAME structural child + // (Type 1, id 1, not the inc/dec button) carries its OWN direct + // Normal/Normal_rollover/Normal_pressed media and has ZERO + // children (live-DAT-probe-confirmed against all three — no + // slice grandchildren to find, so `slices` above is always + // empty for this shape and every Thumb*Sprite stayed 0, + // matching the reported "track+arrows render, no thumb" + // symptom). already falls back + // to a single tiled `ThumbSprite` blit when the cap sprites are + // unset (`ThumbTopSprite != 0 && ThumbBotSprite != 0` gate), so + // the only missing piece is feeding it the thumb's OWN media + // when it has no slice children — additive: a thumb WITH real + // slice children (chat) is unaffected since `slices.Length == 0` + // is false for that shape. + if (slices.Length == 0) + bar.ThumbSprite = DefaultImage(thumb); } return bar; @@ -728,6 +755,14 @@ public static class DatWidgetFactory // ElementInfo.Outline's own default, so this is a no-op for the ~99% of text // elements that don't author it. Outline = info.Outline, + // R2-1 (Campaign CC gate round 1 Batch E): the four text-inset + // margins (dat properties 0x23-0x26 — MarginLeft's own doc + // comment on UiText). Default 0 — a no-op for every element that + // doesn't author them (only consumed by the multi-line path). + MarginLeft = info.MarginLeft, + MarginRight = info.MarginRight, + MarginTop = info.MarginTop, + MarginBottom = info.MarginBottom, }; t.ConfigureDatState(info); @@ -779,7 +814,12 @@ public static class DatWidgetFactory cachedWidth = t.Width; cachedFont = t.DatFont; cachedColor = t.DefaultColor; - float maximumWidth = Math.Max(1f, t.Width - 2f * t.Padding); + // R2-1: shrink by BOTH Padding and the four retail + // margins — see DatRichText.Compose's own comment on + // the same formula. + float maximumWidth = Math.Max( + 1f, + t.Width - (t.Padding + t.MarginLeft) - (t.Padding + t.MarginRight)); Func measure = t.DatFont is { } font ? font.MeasureWidth : static value => value.Length * 8f; @@ -808,7 +848,8 @@ public static class DatWidgetFactory || !state.Properties.Values.TryGetValue(0x17u, out var stateCaption) || stateCaption.Kind != UiPropertyKind.StringInfo) continue; - if (stringResolve?.Invoke(stateCaption.StringInfoValue) is { Length: > 0 } text) + if (NormalizeEscapes(stringResolve?.Invoke(stateCaption.StringInfoValue)) + is { Length: > 0 } text) (stateStrings ??= new Dictionary())[stateId] = text; } if (stateStrings is not null) @@ -876,18 +917,185 @@ public static class DatWidgetFactory button.FaceTop = face.Y; button.FaceWidth = face.Width; button.FaceHeight = face.Height; - button.LabelAlign = UiButton.LabelAlignment.Left; - button.LabelOffsetX = face.X + face.Width + 4f; + + if (!ReferenceEquals(labelInfo, info)) + { + // GF-11c (Campaign CC gate round 1 Batch B): a DISTINCT + // Type-12 caption was lifted (e.g. the Town page's per- + // marker name label, 0x10000409 under each town button — + // live-DAT-probe-confirmed authored rect + Center justify, + // independent of the marker face's own geometry) — honor + // ITS OWN authored rect/justify instead of the face- + // relative offset below, which is only correct when the + // label text is authored DIRECTLY on the button itself, + // immediately beside a single-purpose face segment (the + // heritage/template/Face-Clothes sub-tab row family — + // still handled by the else-branch two lines down, since + // ReferenceEquals(labelInfo, info) is true there). + button.LabelBox = (labelInfo.X, labelInfo.Y, labelInfo.Width, labelInfo.Height); + button.LabelAlign = labelInfo.HJustify == HJustify.Left + ? UiButton.LabelAlignment.Left + : UiButton.LabelAlignment.Center; + } + else + { + button.LabelAlign = UiButton.LabelAlignment.Left; + // F10 (Campaign CC gate round 1 closeout): this +4f gap and + // UiButton.LabelOffsetX's own class-default 3f (used by the + // "no face, not lifted" branch below, AND by any caller — + // e.g. PaperdollController's "Slots" label — that sets + // LabelAlign=Left directly with no DatWidgetFactory + // involvement at all) are DELIBERATELY not the same number, + // not an unreconciled oversight: neither carries a retail + // decomp citation (both are acdream-synthesized small + // insets), and they answer different questions — this one + // is "gap after a REAL adjacent face element" (a geometry- + // derived offset), the other is "default left inset when + // there is no reference geometry at all" (a context-free + // fallback). Moving either number to match the other would + // be an unfounded 1px guess on whichever button currently + // works, not a fix — see DatWidgetFactoryTests' own + // `face.X(0) + face.Width(32) + 4` pin for this exact site. + button.LabelOffsetX = face.X + face.Width + 4f; + } } - else if (!ReferenceEquals(labelInfo, info) && labelInfo.HJustify == HJustify.Left) + else if (labelInfo.HJustify == HJustify.Left) { + // Campaign LA gate round 2 finding 2: the guard used to require + // labelInfo to be a LIFTED Type-12 text child (!ReferenceEquals), + // so a button authoring its OWN HJustify=Left with no separate + // label child — e.g. gmCharacterManagementUI's character-list row + // template (0x21000004/0x100003A5: HJustify=Left, three stateful + // Type-3 highlight-art children, no Type-12 caption child) — fell + // through with LabelAlign left at UiButton's Center default. + // Live-DAT probe confirmed: rowInfo.HJustify=Left, + // authoredFaces.Length=3 (faceSegments, not a single face), no + // Type-12 child, and the built row's LabelAlign came out Center. + // labelInfo.X is only a valid inner-offset when a distinct child + // was actually lifted; for the direct (labelInfo == info) case, + // leave UiButton's own default 3px LabelOffsetX in place — see + // the face-relative +4f branch above (F10) for why this 3px + // default and that 4px gap are deliberately different numbers, + // not an unreconciled asymmetry. button.LabelAlign = UiButton.LabelAlignment.Left; - button.LabelOffsetX = labelInfo.X; + if (!ReferenceEquals(labelInfo, info)) + button.LabelOffsetX = labelInfo.X; + } + + // AP-222 / GF-11b (Campaign CC gate round 1 Batch B): per-state label + // color/outline (dat properties 0x1B/0x21 authored PER STATE on the + // label-bearing element — the Appearance spins' own states, or the + // Town caption child's states) — additive, only non-null when the + // authored dat genuinely carries more than one distinct value. + button.SetPerStateLabelStyle( + ElementReader.BuildPerStateColorMap(labelInfo, 0x1Bu), + ElementReader.BuildPerStateBoolMap(labelInfo, 0x21u)); + + // GF-4a (Campaign CC gate round 1 Batch C): retail's chargen + // display buttons author the caption directly as THEIR OWN P0x17 + // (so `label` above resolved from `info` itself, not a lifted + // child) AND carry a SEPARATE, media-less Type-12 child for the + // live value (gmCGProfessionPage::InitializePage + // @0x00482f90-0x00483062, gmCGSkillsPage::InitializePage + // @0x00481e1c — live-DAT-measured: exactly one Type-12 child, zero + // StateMedia entries). Gated tightly to that exact shape so this + // stays a no-op for every other button (a lifted-caption button + // never reaches here with labelInfo==info; a button with an icon/ + // face child instead of a value child has no media-less Type-12 + // child to find). + if (ReferenceEquals(labelInfo, info) && label is not null) + { + ElementInfo? valueChild = info.Children.FirstOrDefault( + child => child.Type == 12u && child.StateMedia.Count == 0); + if (valueChild is not null) + { + // R4-1 (Campaign CC gate round 1 re-test 3): reflow the value + // child's authored rect through retail's own raw-edge policy + // (UIElement::UpdateForParentSizeChange @0x00462640, ported + // as UiLayoutPolicy) before it becomes ValueBox — see + // ReflowValueChildRect's own doc for why this is needed and + // decomp-cited. + button.ValueBox = ReflowValueChildRect(valueChild, info); + button.ValueFont = valueChild.FontDid != 0u && fontResolve is not null + ? fontResolve(valueChild.FontDid) ?? elementFont + : elementFont; + button.ValueColor = valueChild.FontColor ?? System.Numerics.Vector4.One; + button.ValueAlign = valueChild.HJustify switch + { + HJustify.Left => UiButton.LabelAlignment.Left, + // R4-1: HJustify.Right (raw dat 3/5) previously fell into + // this ternary's Center branch — CalcJustification's own + // ecx_5==3||5 case is a DISTINCT far-edge formula (see + // UiButton.LabelAlignment.Right's own doc), and every + // value child in this family (0x100002f1/0x100002f3) + // authors HJustify Right, live-DAT-confirmed. + HJustify.Right => UiButton.LabelAlignment.Right, + _ => UiButton.LabelAlignment.Center, + }; + // Seed with whatever the child itself authors (typically + // blank) so an unbound button doesn't draw stray leftover + // text before a controller writes a real value. + button.ValueLabel = ResolveAuthoredString(valueChild, stringResolve); + } } return button; } + /// + /// R4-1 (Campaign CC gate round 1 re-test 3): the "Available Skill + /// Credits" value overlapped mid-caption ("Available Skill0Credits") + /// because was built from the value + /// child's RAW authored rect, un-reflowed. Live-DAT probe: the value + /// child (0x100002f3) is BASE-INHERITED across four sibling + /// buttons of DIFFERING widths — Health/Stamina/Mana at 150px share the + /// exact same child id/rect (local X=116) as the wider, 231px Skills + /// credits button, and the child's own OriginalParentWidth (the + /// design-time parent size baked in at whichever button FIRST resolved + /// it — 150, matching Health's own actual width) diverges from Skills + /// credits' actual current parent width (231) — exactly the shape + /// (retail + /// UIElement::UpdateForParentSizeChange @0x00462640, already the + /// production raw-edge reflow for live mounted elements via + /// ) exists to correct. The child's + /// own edge modes (Left=2/Right=1, live-DAT-confirmed) are retail's + /// "track the far edge as the parent grows" reflow: applying them moves + /// the value box from local X=116 to X=197 for Skills credits — landing + /// immediately after the caption's own measured end (~x=196, + /// SkillsCreditsButton_CaptionFitsFullWidth_ValueChildStartsAtMidpoint) + /// instead of colliding mid-caption. Health/Stamina/Mana and the + /// Attribute/Credits value child (whose OWN OriginalParentWidth already + /// matches their actual parent, or whose edge modes are all 0/fixed) + /// reflow to their byte-identical raw rect (deltaX=0 or mode-0 passthrough) + /// — this is additive for every already-correct button, not a per-button + /// special case. + /// + private static (float X, float Y, float Width, float Height) ReflowValueChildRect( + ElementInfo child, ElementInfo parent) + { + float originalParentWidth = child.HasOriginalParentSize ? child.OriginalParentWidth : parent.Width; + float originalParentHeight = child.HasOriginalParentSize ? child.OriginalParentHeight : parent.Height; + + var originalChild = UiPixelRect.FromPositionAndSize( + (int)child.X, (int)child.Y, (int)child.Width, (int)child.Height); + var originalParent = UiPixelRect.FromPositionAndSize( + 0, 0, (int)originalParentWidth, (int)originalParentHeight); + var currentParent = UiPixelRect.FromPositionAndSize( + 0, 0, (int)parent.Width, (int)parent.Height); + // Empty (Width=0/Height=0) "current child" so the static Apply's + // currentChild-preservation branch never engages — every axis comes + // from the Near/Far formula, matching mode 0's own "keep the raw + // authored edge" default for the (frequent) no-anchor case. + var noCurrentChild = new UiPixelRect(0, 0, -1, -1); + + UiPixelRect reflowed = UiLayoutPolicy.Apply( + child.Left, child.Top, child.Right, child.Bottom, + originalChild, originalParent, + noCurrentChild, currentParent); + + return (reflowed.X0, reflowed.Y0, reflowed.Width, reflowed.Height); + } + /// /// Retail UIOption_Checkbox is a UIElement_Button whose visible face is its /// authored indicator child. Its label lives on the option object rather than @@ -947,6 +1155,32 @@ public static class DatWidgetFactory || !info.TryGetEffectiveProperty(0x17u, out var property) || property.Kind != UiPropertyKind.StringInfo) return null; - return stringResolve(property.StringInfoValue); + string? resolved = stringResolve(property.StringInfoValue); + // R2-2 (Campaign CC gate round 1 Batch E): the DAT stores the LITERAL + // two-character escape "\n" (0x5C 0x6E), not a real line break — same + // fact BuildText's own authored-string path already normalized for + // (see that call site's own comment). Centralizing the normalize + // HERE, at the single choke point every P0x17 caption resolution in + // this file goes through (BuildText, BuildButton's own caption AND + // its lifted-child caption, BuildButton's coexisting ValueLabel, + // BuildCheckbox), closes the exact class of bug R2-2 found: a caption + // like the Profession credits button's own "Attribute\n Credits" + // rendered the literal backslash-n because BuildButton never + // normalized while BuildText did. BuildText's own subsequent + // Replace("\\n","\n") is now a harmless no-op (idempotent) — left in + // place rather than removed, since it costs nothing and documents the + // same fact locally. + return NormalizeEscapes(resolved); } + + /// + /// R2-2 (Campaign CC gate round 1 Batch E): the shared escape-normalize + /// applies, pulled out so the + /// per-STATE authored-caption loop below (which resolves a state's own + /// 0x17 directly, bypassing the effective-property resolution + /// wraps) gets the SAME normalize + /// instead of a second, easily-forgotten copy. + /// + private static string? NormalizeEscapes(string? raw) => + raw?.Replace("\\n", "\n").Replace("\r", string.Empty); } diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs index 085d5d2b..6f5edbd5 100644 --- a/src/AcDream.App/UI/Layout/ElementReader.cs +++ b/src/AcDream.App/UI/Layout/ElementReader.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using System.Numerics; using AcDream.App.UI; @@ -225,6 +226,46 @@ public sealed class ElementInfo /// public uint ScrollbarElementId; + /// + /// GF-13 (Campaign CC gate round 1, Batch A): the authored Invisible flag + /// from dat property 0x3B (BoolBaseProperty). Retail + /// UIElement::OnSetAttribute @0x00462d80's case 8 + /// (BaseProperty::GetPropertyName(esi) - 0x33 == 8, i.e. property + /// id 0x33 + 8 = 0x3B): this->vtable->SetVisible(value == 0) — + /// an authored true HIDES the element at construction. Populated the + /// same way as / + /// (recomputed fresh from the effective merged state every call), but this + /// is a PURE DATA ADDITION: the shared / + /// path does not act on it. 1,083 elements + /// author this flag client-wide (docs/ISSUES.md #408, its own separately- + /// gated general-honor item) — only screens that explicitly walk their own + /// mounted subtree and check this field may hide elements by it (see + /// CharacterCreationUiController's chargen-scoped honor, register + /// AP-230). + /// + public bool Invisible; + + /// + /// Campaign CC gate round 1 Batch E (R2-1): the four independent + /// UIElement_Text text-inset margins, dat properties + /// 0x23/0x24/0x25/0x26 (IntegerBaseProperty + /// — UIElement_Text::OnSetAttribute @0x0046a640 cases + /// 0xf/0x10/0x11/0x12, i.e. + /// BaseProperty::GetPropertyName(arg2) - 0x14, writing + /// m_margL/m_margR/m_margU/m_margD). Ctor + /// default is 0 on all four (UIElement_Text::UIElement_Text + /// @0x004686d1-0046872d clears them before any authored value + /// applies). The chargen description boxes author margL=9, + /// margR=26, margU=15, margD=15 (live-DAT-probe-confirmed on + /// 0x100003C4/0x100003E0/0x10000409/ + /// 0x10000404) — this codebase never read these four + /// properties before this fix, so every DAT-imported UiText + /// drew flush against its own outer rect (Padding alone, + /// always 0 for DAT-built text) regardless of what the DAT actually + /// authored. + /// + public int MarginLeft, MarginRight, MarginTop, MarginBottom; + /// /// Resolves a property for a state using retail's DirectState-as-base rule. A /// named state's key overrides DirectState by presence, including false/zero. @@ -401,6 +442,15 @@ public static class ElementReader Outline = derived.Outline || base_.Outline, // OutlineColor: same "non-null derived wins" rule as FontColor. OutlineColor = derived.OutlineColor ?? base_.OutlineColor, + // R2-1: margins follow the same "non-zero derived wins" convention as + // FontDid/ZLevel above — a derived element that authors no margin + // property (0 is ApplyCanonicalLegacyProjection's own unset default, + // matching retail's ctor-cleared default too) inherits the base + // prototype's margin instead of silently zeroing it out. + MarginLeft = derived.MarginLeft != 0 ? derived.MarginLeft : base_.MarginLeft, + MarginRight = derived.MarginRight != 0 ? derived.MarginRight : base_.MarginRight, + MarginTop = derived.MarginTop != 0 ? derived.MarginTop : base_.MarginTop, + MarginBottom = derived.MarginBottom != 0 ? derived.MarginBottom : base_.MarginBottom, // DefaultStateName: derived wins if set; otherwise inherit the base's default. DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName, // This helper merges one element snapshot only. LayoutImporter separately @@ -506,6 +556,20 @@ public static class ElementReader } } + // R2-1 (Campaign CC gate round 1 Batch E): the four text-inset margins + // (0x23 Left / 0x24 Right / 0x25 Up / 0x26 Down, IntegerBaseProperty — + // see MarginLeft's own doc comment for the decomp anchor). Absent + // properties leave the ElementInfo default of 0, matching retail's + // ctor-cleared default. + if (info.TryGetEffectiveInteger(0x23u, out int marginLeft)) + info.MarginLeft = marginLeft; + if (info.TryGetEffectiveInteger(0x24u, out int marginRight)) + info.MarginRight = marginRight; + if (info.TryGetEffectiveInteger(0x25u, out int marginTop)) + info.MarginTop = marginTop; + if (info.TryGetEffectiveInteger(0x26u, out int marginBottom)) + info.MarginBottom = marginBottom; + // Tab table (0x2E): array of StructBaseProperty (MasterPropertyId 0x2F) — the // Type-8 tab control's authored {button element, page element, isDefault} rows // (docs/research/2026-08-10-options-panel-structure.md §1.3). Recomputed fresh @@ -529,6 +593,16 @@ public static class ElementReader // (DataId), UnsignedValue 100683031/100683033 == 0x06004D17/0x06004D19). info.LedCheckedSprite = ReadReferencedElementId(info, 0x10000082u); info.LedUncheckedSprite = ReadReferencedElementId(info, 0x10000083u); + + // GF-13: Invisible (0x3B), BoolBaseProperty. Retail + // UIElement::OnSetAttribute @0x00462d80 case 8 — SetVisible(value == 0), + // so an authored true HIDES the element. Read via the same + // TryGetEffectiveBool the DirectState/default-state resolution rules + // already use for every other canonical-projection property above. + if (info.TryGetEffectiveBool(0x3Bu, out bool invisible)) + { + info.Invisible = invisible; + } } private static List ReadTabTable(ElementInfo info) @@ -644,4 +718,65 @@ public static class ElementReader }) .ToArray(); } + + /// + /// AP-222 / GF-11b (Campaign CC gate round 1 Batch B): resolves a color + /// property (0x1B FontColor's Array-tolerant shape, same unwrap as + /// ) for EVERY state itself authors, keyed by retail numeric state id. + /// Returns null unless at least two states resolve to GENUINELY + /// DIFFERENT colors — the overwhelming majority of elements author one + /// color for every state (or none at all), and for those this returns + /// null so the caller keeps its existing single-default-color behavior + /// untouched. Only elements that really do recolor per state (the + /// Appearance spins' Highlight brightening, the Town buttons' Normal- + /// to-white caption swap) get a non-null map. + /// + internal static IReadOnlyDictionary? BuildPerStateColorMap( + ElementInfo info, uint propertyId) + { + Dictionary? map = null; + foreach (uint stateId in info.States.Keys) + { + if (!info.TryGetEffectiveProperty(propertyId, out UiPropertyValue value, stateId)) + continue; + + UiPropertyValue? colorValue = value.Kind == UiPropertyKind.Color + ? value + : value.Kind == UiPropertyKind.Array + && value.ArrayValue.Count > 0 + && value.ArrayValue[0].Kind == UiPropertyKind.Color + ? value.ArrayValue[0] + : null; + if (colorValue is null) + continue; + + UiColorValue c = colorValue.ColorValue; + float alpha = c.Alpha == 0 ? 1f : c.Alpha / 255f; + (map ??= new Dictionary())[stateId] = + new Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, alpha); + } + + return map is { Count: > 1 } && map.Values.Distinct().Count() > 1 ? map : null; + } + + /// + /// AP-222 counterpart of for a bool + /// property (0x21 Outline) — same "null unless genuinely per-state" + /// gating. + /// + internal static IReadOnlyDictionary? BuildPerStateBoolMap( + ElementInfo info, uint propertyId) + { + Dictionary? map = null; + foreach (uint stateId in info.States.Keys) + { + if (!info.TryGetEffectiveProperty(propertyId, out UiPropertyValue value, stateId) + || value.Kind != UiPropertyKind.Bool) + continue; + (map ??= new Dictionary())[stateId] = value.BoolValue; + } + + return map is { Count: > 1 } && map.Values.Distinct().Count() > 1 ? map : null; + } } diff --git a/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs b/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs index 82400411..4ebaaaf4 100644 --- a/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs +++ b/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs @@ -1715,8 +1715,11 @@ public static class ItemAppraisalTextFormatter _ => string.Empty, }; - /// AppraisalSystem::SkillToString @ 0x005B4A30. - private static string SkillName(int skill) => skill switch + /// AppraisalSystem::SkillToString @ 0x005B4A30 — retail + /// skill-id -> display-name table. Made internal (Campaign CC + /// slice CC4) so the chargen Skills page can reuse the same names + /// instead of duplicating this table. + internal static string SkillName(int skill) => skill switch { 1 => "Axe", 2 => "Bow", diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index 3c3f72d4..bb411eb4 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -117,6 +117,10 @@ public static class LayoutImporter var w = DatWidgetFactory.Create(info, resolve, datFont, fontResolve, stringResolve); if (w is null) return null; // Type-12 style prototype — skip + // GF-13: pure data passthrough — see UiElement.AuthoredInvisible's own + // doc comment for why this does NOT set Visible here. + w.AuthoredInvisible = info.Invisible; + if (info.Id != 0) byId[info.Id] = w; // Behavioral widgets that draw their full appearance + reproduce their dat @@ -160,6 +164,48 @@ public static class LayoutImporter if (cw is not null) w.AddChild(cw); } } + else if (w is UiText or UiField) + { + // Campaign CC gate round 1 Batch C, Commit 2: UiText/UiField's + // coarse ConsumesDatChildren=true (UiText outside its + // PassToChildren carve-out; UiField unconditionally) used to + // drop EVERY dat child, including ones that carry their own + // renderable media — retail's UIElement_Text/Field genuinely + // composites those as real chrome/controls, not swallowed + // caption/face art the way a Button's or Meter's children are. + // Live-DAT-measured (chargen's three shared description boxes, + // 0x100003e0/0x10000409/0x10000404): the eight gold-frame + // pieces (0x100002DE-E3, 0x100000E8/EA, Type 3, one DirectState + // sprite each) and the linked scrollbar (0x100002E7, Type 11, + // its own DirectState track sprite plus three Button + // sub-children BuildScrollbar resolves internally) all carry + // non-empty StateMedia on THEMSELVES. Purely structural/ + // property-only children (StateMedia.Count == 0 — e.g. a + // lifted-caption-only child some OTHER element type might + // still want swallowed) stay dropped exactly as before; this + // is additive, not a relaxation of the PassToChildren gate + // itself. + foreach (var child in info.Children) + { + if (child.StateMedia.Count == 0) continue; + var cw = BuildWidget(child, resolve, datFont, fontResolve, stringResolve, byId); + if (cw is null) continue; + // F5/F6 (Campaign CC gate round 1 closeout): a NARROW honor + // of AuthoredInvisible, scoped to children reached through + // THIS carve-out only — e.g. the chat new-text indicator + // (0x1000048C, live-DAT-confirmed Invisible=true on every + // layout it appears in) would otherwise render as a phantom + // element retail never shows, now that this carve-out + // builds it as a real widget instead of silently dropping + // it. This is NOT the general client-wide honor (#408, + // 1,083 elements) — every OTHER AuthoredInvisible consumer + // stays data-only, acted on nowhere but chargen's own + // HideAuthoredInvisibleElements walk (register AP-230). + if (cw.AuthoredInvisible) + cw.Visible = false; + w.AddChild(cw); + } + } // UIElement::SetState @ 0x00464E70 propagates a state's id only after the // child tree exists. Re-applying the imported default here gives retained diff --git a/src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs b/src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs index 71cc4673..d21dbddc 100644 --- a/src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs +++ b/src/AcDream.App/UI/Layout/RetailConfirmationDialogView.cs @@ -129,10 +129,15 @@ internal sealed class RetailConfirmationDialogView : IRetailDialogView private void SizeAndCenter() { + // Center against the space the tree lays out in — the fixed authored + // canvas while a pre-world screen is active (gate round 2: centering + // against the raw window width put the exit dialog far right of the + // stretched 800x600 canvas center). + var space = _host.EffectiveCanvasSize; Root.Left = 0f; Root.Top = 0f; - Root.Width = _host.Width; - Root.Height = _host.Height; + Root.Width = space.X; + Root.Height = space.Y; _popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f); _popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f); } diff --git a/src/AcDream.App/UI/Layout/RetailConfirmationTextInputDialogView.cs b/src/AcDream.App/UI/Layout/RetailConfirmationTextInputDialogView.cs new file mode 100644 index 00000000..1b1467c8 --- /dev/null +++ b/src/AcDream.App/UI/Layout/RetailConfirmationTextInputDialogView.cs @@ -0,0 +1,163 @@ +namespace AcDream.App.UI.Layout; + +/// +/// Retail type-5 ConfirmationTextInputDialog (class type +/// 0x15, catalog root 0x2C). Accept stores the field text under +/// property 0x9C; reject/Escape stores the empty string. Character +/// deletion is the first consumer and performs retail's case-insensitive +/// comparison with the localized DELETE response in its callback. +/// +internal sealed class RetailConfirmationTextInputDialogView : IRetailDialogView +{ + public const uint RootElementId = 0x2Cu; + public const uint InputElementId = 0x2Cu; + public const uint AcceptButtonId = 0x2Eu; + public const uint RejectButtonId = 0x2Fu; + public const uint PopupElementId = 0x3Du; + public const uint MessageElementId = 0x3Eu; + + private readonly UiRoot _host; + private readonly RetailDialogData _data; + private readonly uint _context; + private readonly Action _closeDialog; + private readonly UiElement _popup; + private readonly UiText _message; + private readonly UiField _input; + private readonly UiButton _accept; + private readonly UiButton _reject; + private readonly float _basePopupHeight; + private readonly float _baseMessageHeight; + private bool _focusPending = true; + + public RetailConfirmationTextInputDialogView( + UiRoot host, + ImportedLayout layout, + RetailDialogData data, + uint context, + Action closeDialog) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + ArgumentNullException.ThrowIfNull(layout); + _data = data ?? throw new ArgumentNullException(nameof(data)); + _context = context; + _closeDialog = closeDialog ?? throw new ArgumentNullException(nameof(closeDialog)); + + Root = layout.Root as UiDialogRoot + ?? throw new ArgumentException( + "Confirmation-text-input layout root is not a UiDialogRoot.", + nameof(layout)); + _popup = layout.FindElement(PopupElementId) + ?? throw new ArgumentException( + "Confirmation-text-input layout is missing popup element 0x3D.", + nameof(layout)); + _message = layout.FindElement(MessageElementId) as UiText + ?? throw new ArgumentException( + "Confirmation-text-input layout is missing text element 0x3E.", + nameof(layout)); + // The field deliberately repeats the root's numeric id. ImportedLayout + // registers descendants after ancestors, matching GetChildRecursive's + // effective result for this catalog shape. + _input = layout.FindElement(InputElementId) as UiField + ?? throw new ArgumentException( + "Confirmation-text-input layout is missing input field 0x2C.", + nameof(layout)); + _accept = layout.FindElement(AcceptButtonId) as UiButton + ?? throw new ArgumentException( + "Confirmation-text-input layout is missing accept button 0x2E.", + nameof(layout)); + _reject = layout.FindElement(RejectButtonId) as UiButton + ?? throw new ArgumentException( + "Confirmation-text-input layout is missing reject button 0x2F.", + nameof(layout)); + + _basePopupHeight = _popup.Height; + _baseMessageHeight = _message.Height; + _popup.LayoutPolicy = null; + _popup.Anchors = AnchorEdges.None; + _message.LayoutPolicy = null; + _message.Anchors = AnchorEdges.None; + _message.Padding = 0f; + _message.Selectable = false; + _input.ClearOnSubmit = false; + _input.RecordHistory = false; + + if (_data.GetString(RetailDialogProperty.TextInputAcceptLabel) is { } acceptLabel) + _accept.Label = acceptLabel; + if (_data.GetString(RetailDialogProperty.TextInputRejectLabel) is { } rejectLabel) + _reject.Label = rejectLabel; + + Root.Cancel = Reject; + _accept.OnClick = Accept; + _reject.OnClick = Reject; + _input.OnSubmit = _ => Accept(); + SetMessage(_data.GetString(RetailDialogProperty.Message) ?? string.Empty); + SizeAndCenter(); + } + + public UiDialogRoot Root { get; } + + public void Tick() + { + SizeAndCenter(); + if (_focusPending && Root.Parent is not null) + { + _host.SetKeyboardFocus(_input); + _focusPending = false; + } + } + + public void SetPendingCount(int count) + { + // This catalog root authors no pending-count display. + } + + public void DetachHandlers() + { + Root.Cancel = null; + _accept.OnClick = null; + _reject.OnClick = null; + _input.OnSubmit = null; + } + + private void Accept() + { + _data.Set(RetailDialogProperty.TextInputResult, _input.Text); + _closeDialog(_context); + } + + private void Reject() + { + _data.Set(RetailDialogProperty.TextInputResult, string.Empty); + _closeDialog(_context); + } + + private void SetMessage(string text) + { + float maximumWidth = Math.Max(1f, _message.Width - 2f * _message.Padding); + Func measure = _message.DatFont is { } font + ? font.MeasureWidth + : static value => value.Length * 8f; + IReadOnlyList wrapped = UiText.WrapWords(text, measure, maximumWidth); + var lines = new UiText.Line[wrapped.Count]; + for (int i = 0; i < wrapped.Count; i++) + lines[i] = new UiText.Line(wrapped[i], _message.DefaultColor); + _message.LinesProvider = () => lines; + + float lineHeight = _message.DatFont?.LineHeight ?? 16f; + _message.Height = Math.Max(_baseMessageHeight, lines.Length * lineHeight); + _popup.Height = _basePopupHeight + (_message.Height - _baseMessageHeight); + } + + private void SizeAndCenter() + { + // Center against the layout space (fixed canvas while a pre-world + // screen is active) — see RetailConfirmationDialogView.SizeAndCenter. + var space = _host.EffectiveCanvasSize; + Root.Left = 0f; + Root.Top = 0f; + Root.Width = space.X; + Root.Height = space.Y; + _popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f); + _popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f); + } +} diff --git a/src/AcDream.App/UI/Layout/RetailDialogData.cs b/src/AcDream.App/UI/Layout/RetailDialogData.cs index 72512826..3dd769e5 100644 --- a/src/AcDream.App/UI/Layout/RetailDialogData.cs +++ b/src/AcDream.App/UI/Layout/RetailDialogData.cs @@ -11,6 +11,9 @@ public static class RetailDialogProperty public const uint AcceptLabel = 0x90u; public const uint RejectLabel = 0x91u; public const uint ConfirmationResult = 0x92u; + public const uint TextInputAcceptLabel = 0x9Au; + public const uint TextInputRejectLabel = 0x9Bu; + public const uint TextInputResult = 0x9Cu; /// /// When true, Dialog::SetData @ 0x00476BE0 sets UIElement boolean /// attribute 0x40. The Keystone-owned attribute name is unavailable. @@ -105,11 +108,16 @@ public sealed class RetailDialogData return clone; } + /// Type-1 confirmation data. Sets element attribute 0x40 — retail's + /// own confirmation builders do (e.g. MakeConfirmExitDialog @0x004ed250 + /// writes 0x8E=1, 0xAC=1, 0xC5=message), same as the Wait/TextInput factories + /// below (gate-round-2 batch review F5). public static RetailDialogData Confirmation(string message) { ArgumentNullException.ThrowIfNull(message); return new RetailDialogData() .Set(RetailDialogProperty.Type, RetailDialogType.Confirmation) + .Set(RetailDialogProperty.ElementAttribute40, true) .Set(RetailDialogProperty.Message, message); } @@ -123,4 +131,21 @@ public sealed class RetailDialogData .Set(RetailDialogProperty.ElementAttribute40, true) .Set(RetailDialogProperty.Message, message); } + + public static RetailDialogData Message(string message) + { + ArgumentNullException.ThrowIfNull(message); + return new RetailDialogData() + .Set(RetailDialogProperty.Type, RetailDialogType.Message) + .Set(RetailDialogProperty.Message, message); + } + + public static RetailDialogData ConfirmationTextInput(string message) + { + ArgumentNullException.ThrowIfNull(message); + return new RetailDialogData() + .Set(RetailDialogProperty.Type, RetailDialogType.ConfirmationTextInput) + .Set(RetailDialogProperty.ElementAttribute40, true) + .Set(RetailDialogProperty.Message, message); + } } diff --git a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs index 554c525c..9efc211c 100644 --- a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs +++ b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs @@ -15,6 +15,7 @@ public sealed class RetailDialogFactory : IDisposable public required RetailDialogData Data { get; init; } public required uint Context { get; init; } public required uint QueueKey { get; init; } + public required ulong Sequence { get; init; } public Action? Callback { get; init; } public IRetailDialogView? View { get; set; } } @@ -24,8 +25,10 @@ public sealed class RetailDialogFactory : IDisposable private readonly Dictionary _activeQueued = new(); private readonly Dictionary _activeNonQueued = new(); private readonly Dictionary> _pending = new(); + private readonly LinkedList _retryable = new(); private readonly List _openOrder = new(); private uint _globalContext; + private ulong _globalSequence; private bool _resetting; private bool _disposed; @@ -51,6 +54,8 @@ public sealed class RetailDialogFactory : IDisposable public int PendingCount => _pending.Values.Sum(static queue => queue.Count); + internal int RetryCount => _retryable.Count; + /// Exact root-element switch from CreateDialog_ @ 0x00477AD0. public static uint RootElementId(RetailDialogType type) => type switch @@ -87,25 +92,40 @@ public sealed class RetailDialogFactory : IDisposable Data = ownedData, Context = context, QueueKey = queueKey, + Sequence = NextSequence(), Callback = callback, }; if (queueKey == NonQueuedKey) { _activeNonQueued.Add(context, info); - CreateDialog(info); + if (!TryCreateDialog(info)) + { + _activeNonQueued.Remove(context); + QueueRetry(info); + } return context; } if (!_activeQueued.TryGetValue(queueKey, out DialogInfo? current)) { + if (HasRetry(queueKey) && !IsPriority(info)) + { + PendingQueue(queueKey).AddLast(info); + return context; + } + _activeQueued.Add(queueKey, info); - CreateDialog(info); + if (!TryCreateDialog(info)) + { + _activeQueued.Remove(queueKey); + QueueRetry(info); + } return context; } LinkedList queue = PendingQueue(queueKey); - if (!ownedData.GetBoolean(RetailDialogProperty.Priority)) + if (!IsPriority(info)) { queue.AddLast(info); UpdatePendingDialogDisplays(); @@ -118,7 +138,15 @@ public sealed class RetailDialogFactory : IDisposable Suspend(current); queue.AddFirst(current); _activeQueued[queueKey] = info; - CreateDialog(info); + if (!TryCreateDialog(info)) + { + _activeQueued.Remove(queueKey); + queue.Remove(current); + if (queue.Count == 0) + _pending.Remove(queueKey); + OpenSpecificDialog(current); + QueueRetry(info); + } return context; } @@ -148,6 +176,26 @@ public sealed class RetailDialogFactory : IDisposable return MakeDialog(data, callback: null); } + public uint MakeMessage( + string message, + Action? callback = null, + uint queueKey = DefaultQueueKey) + { + RetailDialogData data = RetailDialogData.Message(message) + .Set(RetailDialogProperty.QueueKey, queueKey); + return MakeDialog(data, callback); + } + + public uint MakeConfirmationTextInput( + string message, + Action? callback = null, + uint queueKey = DefaultQueueKey) + { + RetailDialogData data = RetailDialogData.ConfirmationTextInput(message) + .Set(RetailDialogProperty.QueueKey, queueKey); + return MakeDialog(data, callback); + } + /// /// Retail CloseDialog @ 0x00478160. The context can identify an active /// nonqueued dialog, an active queued dialog, or an item still pending in a queue. @@ -191,13 +239,69 @@ public sealed class RetailDialogFactory : IDisposable return true; } + LinkedListNode? retry = _retryable.First; + while (retry is not null && retry.Value.Context != context) + retry = retry.Next; + if (retry is not null) + { + DialogInfo failed = retry.Value; + _retryable.Remove(retry); + DialogDone(failed); + if (failed.QueueKey != NonQueuedKey) + OpenNextDialog(failed.QueueKey); + return true; + } + return false; } + /// + /// GF-15 fix (Campaign CC gate round 1, Batch A, 2026-08-16). Live-repro- + /// confirmed root cause: CharacterCreationUiController.Tick and + /// CharacterManagementUiController.Tick both call + /// UiRoot.BringToFront(Root) UNCONDITIONALLY on every frame while + /// their screen is open — a per-tick "stay on top of my sibling screen" + /// assertion (needed so chargen never bleeds input to the occluded + /// char-management screen underneath it, register AP-229). A dialog this + /// factory opens is ALSO a direct sibling of those screen roots under + /// the same UiRoot (_host.AddChild(view.Root) in + /// ), competing for the SAME z-order slot. + /// is a simple "highest + /// ZOrder among _root's direct children + 1" — whichever sibling's + /// own BringToFront call runs LAST in a frame wins the top slot. + /// Before this fix, this method never re-asserted a dialog's own + /// z-order after the one-time raise in , so + /// the VERY NEXT frame's screen Tick() (which always runs before + /// this factory's own Tick() in + /// RetailUiRuntime.Tick(double)'s per-frame sequence) silently + /// buried the dialog behind the screen's opaque backdrop — while the + /// dialog remained the registered and kept + /// EXCLUSIVE input priority (OnMouseDown's Modal-vs-bounds gate is + /// independent of render/z-order). The user-visible symptom: press + /// Finish empty → the NoName dialog is created successfully + /// (visible=true, correct geometry, live-DAT-probe-confirmed) but + /// renders NOTHING, and every subsequent click across the WHOLE canvas + /// resolves to the invisible dialog root instead of the name field or + /// Finish button underneath — both GF-15 symptoms from one mechanism. + /// Retail's real dialogs are always-on-top overlays by construction (a + /// separate presentation layer, not a z-ordered sibling of the game UI); + /// re-asserting every open dialog's z-order here, every tick, in + /// order (so the MOST RECENTLY opened dialog — + /// the same one already treats as + /// authoritative — ends up on top) reproduces that invariant without + /// touching either screen controller's own already-verified raise. + /// public void Tick() { + RetryFailedDialogs(); foreach (DialogInfo info in _openOrder.ToArray()) - info.View?.Tick(); + { + if (info.View is { } view) + { + _host.BringToFront(view.Root); + view.Tick(); + } + } } /// @@ -218,6 +322,7 @@ public sealed class RetailDialogFactory : IDisposable DialogInfo[] infos = _activeNonQueued.Values .Concat(_activeQueued.Values) .Concat(_pending.Values.SelectMany(static queue => queue)) + .Concat(_retryable) .Distinct() .ToArray(); if (infos.Length == 0) @@ -229,6 +334,7 @@ public sealed class RetailDialogFactory : IDisposable _activeNonQueued.Clear(); _activeQueued.Clear(); _pending.Clear(); + _retryable.Clear(); foreach (DialogInfo info in infos) { try { DialogDone(info); } @@ -263,6 +369,14 @@ public sealed class RetailDialogFactory : IDisposable return _globalContext; } + private ulong NextSequence() + { + _globalSequence++; + if (_globalSequence == 0uL) + _globalSequence++; + return _globalSequence; + } + private LinkedList PendingQueue(uint queueKey) { if (_pending.TryGetValue(queueKey, out LinkedList? queue)) @@ -272,30 +386,57 @@ public sealed class RetailDialogFactory : IDisposable return queue; } - private void CreateDialog(DialogInfo info) + private bool TryCreateDialog(DialogInfo info) { - RetailDialogType type = (RetailDialogType)info.Data.GetUInt32(RetailDialogProperty.Type); - if (type is not (RetailDialogType.Confirmation or RetailDialogType.Wait)) - throw new NotSupportedException( - $"Retail dialog type {(uint)type} does not have a ported presenter yet."); - - ImportedLayout layout = _createLayout(type) - ?? throw new InvalidOperationException( - $"Retail dialog catalog could not create type {(uint)type}."); - IRetailDialogView view = type switch + RetailDialogType type = (RetailDialogType)info.Data.GetUInt32( + RetailDialogProperty.Type); + try { - RetailDialogType.Wait => new RetailWaitDialogView(_host, layout, info.Data), - _ => new RetailConfirmationDialogView( - _host, layout, info.Data, info.Context, - context => CloseDialog(context)), - }; - info.View = view; - _host.AddChild(view.Root); - _host.BringToFront(view.Root); - _openOrder.Add(info); - _host.Modal = view.Root; - UpdatePendingDialogDisplays(); - DialogOpened?.Invoke(info.Context); + if (type is not (RetailDialogType.Confirmation + or RetailDialogType.Wait + or RetailDialogType.Message + or RetailDialogType.ConfirmationTextInput)) + { + throw new NotSupportedException( + $"Retail dialog type {(uint)type} does not have a ported presenter yet."); + } + + ImportedLayout layout = _createLayout(type) + ?? throw new InvalidOperationException( + $"Retail dialog catalog could not create type {(uint)type}."); + IRetailDialogView view = type switch + { + RetailDialogType.Wait => new RetailWaitDialogView( + _host, layout, info.Data), + RetailDialogType.Message => new RetailMessageDialogView( + _host, layout, info.Data, info.Context, + context => CloseDialog(context)), + RetailDialogType.ConfirmationTextInput => + new RetailConfirmationTextInputDialogView( + _host, layout, info.Data, info.Context, + context => CloseDialog(context)), + _ => new RetailConfirmationDialogView( + _host, layout, info.Data, info.Context, + context => CloseDialog(context)), + }; + info.View = view; + _host.AddChild(view.Root); + _host.BringToFront(view.Root); + _openOrder.Add(info); + _host.Modal = view.Root; + view.Tick(); + UpdatePendingDialogDisplays(); + DialogOpened?.Invoke(info.Context); + return true; + } + catch (Exception error) + { + RemoveView(info); + Console.WriteLine( + $"[UI] retail dialog type {(uint)type} context {info.Context} " + + $"will retry after catalog recovery: {error.Message}"); + return false; + } } private void Suspend(DialogInfo info) @@ -349,6 +490,9 @@ public sealed class RetailDialogFactory : IDisposable if (_activeQueued.ContainsKey(queueKey)) return; + if (TryActivateRetry(queueKey)) + return; + if (!_pending.TryGetValue(queueKey, out LinkedList? queue) || queue.First is null) return; @@ -358,7 +502,115 @@ public sealed class RetailDialogFactory : IDisposable if (queue.Count == 0) _pending.Remove(queueKey); _activeQueued.Add(queueKey, next); - CreateDialog(next); + if (!TryCreateDialog(next)) + { + _activeQueued.Remove(queueKey); + QueueRetry(next); + } + } + + private void OpenSpecificDialog(DialogInfo info) + { + _activeQueued.Add(info.QueueKey, info); + if (!TryCreateDialog(info)) + { + _activeQueued.Remove(info.QueueKey); + QueueRetry(info); + } + } + + private void RetryFailedDialogs() + { + foreach (DialogInfo info in _retryable.ToArray()) + { + if (info.QueueKey == NonQueuedKey) + { + _activeNonQueued.Add(info.Context, info); + if (TryCreateDialog(info)) + _retryable.Remove(info); + else + _activeNonQueued.Remove(info.Context); + continue; + } + + if (!ReferenceEquals(FirstRetry(info.QueueKey), info)) + continue; + + if (!_activeQueued.TryGetValue( + info.QueueKey, + out DialogInfo? active)) + TryActivateRetry(info.QueueKey); + else if (IsPriority(info) + && (!IsPriority(active) || info.Sequence > active.Sequence)) + TryPreemptWithRetry(info, active); + } + } + + private void TryPreemptWithRetry(DialogInfo priority, DialogInfo current) + { + LinkedList queue = PendingQueue(priority.QueueKey); + Suspend(current); + queue.AddFirst(current); + _activeQueued[priority.QueueKey] = priority; + _retryable.Remove(priority); + if (TryCreateDialog(priority)) + return; + + _activeQueued.Remove(priority.QueueKey); + queue.Remove(current); + if (queue.Count == 0) + _pending.Remove(priority.QueueKey); + OpenSpecificDialog(current); + QueueRetry(priority); + } + + private bool TryActivateRetry(uint queueKey) + { + DialogInfo? info = FirstRetry(queueKey); + if (info is null) + return false; + + _activeQueued.Add(queueKey, info); + if (TryCreateDialog(info)) + _retryable.Remove(info); + else + _activeQueued.Remove(queueKey); + return true; + } + + private DialogInfo? FirstRetry(uint queueKey) + { + foreach (DialogInfo info in _retryable) + if (info.QueueKey == queueKey) + return info; + return null; + } + + private bool HasRetry(uint queueKey) => FirstRetry(queueKey) is not null; + + private static bool IsPriority(DialogInfo info) => + info.Data.GetBoolean(RetailDialogProperty.Priority); + + private void QueueRetry(DialogInfo info) + { + if (_retryable.Contains(info)) + return; + if (!IsPriority(info)) + { + _retryable.AddLast(info); + return; + } + + LinkedListNode? existing = _retryable.First; + while (existing is not null + && existing.Value.QueueKey != info.QueueKey) + { + existing = existing.Next; + } + if (existing is null) + _retryable.AddLast(info); + else + _retryable.AddBefore(existing, info); } private void UpdatePendingDialogDisplays() diff --git a/src/AcDream.App/UI/Layout/RetailMessageDialogView.cs b/src/AcDream.App/UI/Layout/RetailMessageDialogView.cs new file mode 100644 index 00000000..9ec4d987 --- /dev/null +++ b/src/AcDream.App/UI/Layout/RetailMessageDialogView.cs @@ -0,0 +1,108 @@ +namespace AcDream.App.UI.Layout; + +/// +/// Retail type-3 MessageDialog (class type 0x17, catalog root +/// 0x24). It shares the dialog catalog's popup/message pair with the +/// existing confirmation and wait presenters and closes from its authored OK +/// button 0x26 or Escape. +/// +internal sealed class RetailMessageDialogView : IRetailDialogView +{ + public const uint RootElementId = 0x24u; + public const uint OkButtonId = 0x26u; + public const uint PopupElementId = 0x3Du; + public const uint MessageElementId = 0x3Eu; + + private readonly UiRoot _host; + private readonly uint _context; + private readonly Action _closeDialog; + private readonly UiElement _popup; + private readonly UiText _message; + private readonly UiButton _ok; + private readonly float _basePopupHeight; + private readonly float _baseMessageHeight; + + public RetailMessageDialogView( + UiRoot host, + ImportedLayout layout, + RetailDialogData data, + uint context, + Action closeDialog) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(data); + _context = context; + _closeDialog = closeDialog ?? throw new ArgumentNullException(nameof(closeDialog)); + + Root = layout.Root as UiDialogRoot + ?? throw new ArgumentException("Message layout root is not a UiDialogRoot.", nameof(layout)); + _popup = layout.FindElement(PopupElementId) + ?? throw new ArgumentException("Message layout is missing popup element 0x3D.", nameof(layout)); + _message = layout.FindElement(MessageElementId) as UiText + ?? throw new ArgumentException("Message layout is missing text element 0x3E.", nameof(layout)); + _ok = layout.FindElement(OkButtonId) as UiButton + ?? throw new ArgumentException("Message layout is missing OK button 0x26.", nameof(layout)); + + _basePopupHeight = _popup.Height; + _baseMessageHeight = _message.Height; + _popup.LayoutPolicy = null; + _popup.Anchors = AnchorEdges.None; + _message.LayoutPolicy = null; + _message.Anchors = AnchorEdges.None; + _message.Padding = 0f; + _message.Selectable = false; + + Root.Cancel = Close; + _ok.OnClick = Close; + SetMessage(data.GetString(RetailDialogProperty.Message) ?? string.Empty); + SizeAndCenter(); + } + + public UiDialogRoot Root { get; } + + public void Tick() => SizeAndCenter(); + + public void SetPendingCount(int count) + { + // MessageDialog has no pending-count subtree in the retail catalog. + } + + public void DetachHandlers() + { + Root.Cancel = null; + _ok.OnClick = null; + } + + private void Close() => _closeDialog(_context); + + private void SetMessage(string text) + { + float maximumWidth = Math.Max(1f, _message.Width - 2f * _message.Padding); + Func measure = _message.DatFont is { } font + ? font.MeasureWidth + : static value => value.Length * 8f; + IReadOnlyList wrapped = UiText.WrapWords(text, measure, maximumWidth); + var lines = new UiText.Line[wrapped.Count]; + for (int i = 0; i < wrapped.Count; i++) + lines[i] = new UiText.Line(wrapped[i], _message.DefaultColor); + _message.LinesProvider = () => lines; + + float lineHeight = _message.DatFont?.LineHeight ?? 16f; + _message.Height = Math.Max(_baseMessageHeight, lines.Length * lineHeight); + _popup.Height = _basePopupHeight + (_message.Height - _baseMessageHeight); + } + + private void SizeAndCenter() + { + // Center against the layout space (fixed canvas while a pre-world + // screen is active) — see RetailConfirmationDialogView.SizeAndCenter. + var space = _host.EffectiveCanvasSize; + Root.Left = 0f; + Root.Top = 0f; + Root.Width = space.X; + Root.Height = space.Y; + _popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f); + _popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f); + } +} diff --git a/src/AcDream.App/UI/Layout/RetailWaitDialogView.cs b/src/AcDream.App/UI/Layout/RetailWaitDialogView.cs index 39355361..b5175509 100644 --- a/src/AcDream.App/UI/Layout/RetailWaitDialogView.cs +++ b/src/AcDream.App/UI/Layout/RetailWaitDialogView.cs @@ -95,10 +95,15 @@ internal sealed class RetailWaitDialogView : IRetailDialogView private void SizeAndCenter() { + // Center against the layout space (fixed canvas while a pre-world + // screen is active) — gate-round-2 batch review F1: this was the ONE + // dialog view the 0a7dc7d6 sweep missed, and it fires on ENTER (the + // char screen's primary action), centering off the visible canvas. + var space = _host.EffectiveCanvasSize; Root.Left = 0f; Root.Top = 0f; - Root.Width = _host.Width; - Root.Height = _host.Height; + Root.Width = space.X; + Root.Height = space.Y; _popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f); _popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f); } diff --git a/src/AcDream.App/UI/Layout/UiDatElement.cs b/src/AcDream.App/UI/Layout/UiDatElement.cs index 584a4d5e..7e70a5d6 100644 --- a/src/AcDream.App/UI/Layout/UiDatElement.cs +++ b/src/AcDream.App/UI/Layout/UiDatElement.cs @@ -179,6 +179,15 @@ public class UiDatElement : UiElement, IUiDatStateful /// Label color (default white). public Vector4 LabelColor { get; set; } = Vector4.One; + /// + /// Campaign CC gate round 1 closeout (Group 1, R2-5): per-instance + /// multiplicative sprite tint, threaded into both + /// calls this class makes (the runtime-image path and the ordinary + /// authored-media path) — same shape and same default-identity + /// no-op-for-existing-callers guarantee as . + /// + public Vector4 Tint { get; set; } = Vector4.One; + /// Retail LayoutDesc property 0x21 (two-pass glyph outline, /// UIElement_Text::SetOutline @0x0046a81c). Seeded in the ctor from the /// element's effective-default state, same as @@ -205,6 +214,56 @@ public class UiDatElement : UiElement, IUiDatStateful /// public uint? RuntimeImageTexture { get; set; } + /// + /// Retail background-blit ground truth (Campaign LA gate round 2, register + /// AD-98). Every element draws its own media with the native-pixel TILE + /// formula below — retail has no per-element stretch, and neither do we. + /// + /// + /// Campaign LA gate round 2 (issue found in the live client: the LA8 + /// character-select background repeated across the window instead of scaling + /// with it). Retail's generic UI sprite blit — + /// Graphic::Draw (acclient 0x00693b20) dispatching to + /// Graphic::PutImage (0x00693a30) for an exact/undersized destination, or a + /// modulo-wrapped tile loop otherwise — has exactly two behaviors, copy or tile; + /// it can never scale a source image up to a larger destination. This is confirmed + /// against two candidate "draw-mode" fields that could have carried a stretch bit + /// and don't: BlitMode (acclient.h ~line 3135 — Blit_Normal/3Alpha/4Alpha/ + /// Colorize/Multiply/Screen/Grayscale/NOP are all COLOR-BLEND selectors) and + /// MD_Data_Image::m_drawMode/DrawModeType (Undefined/Normal/Overlay/ + /// Alphablend — also a blend selector; the "Normal → tile" reading in + /// docs/research/2026-06-15-layoutdesc-format.md §6 cited + /// ImgTex::TileCSI (0x0053e740), but that function is exclusively called from + /// TexMerge::CopyAndTile/ImgTex::CopyCSI for LAND-SURFACE terrain + /// texture compositing (TerrainTex) — never from the UI element system; the + /// citation was a coincidental name match, not the real call site). + /// + /// + /// + /// The LA8 root itself (0x1000039A) authors LeftEdge=TopEdge=RightEdge=BottomEdge=0 + /// ("no anchor" — confirmed against the installed DAT via + /// CharacterManagementLiveDatTests.RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf), + /// so retail's own UIElement::UpdateForParentSizeChange (0x00462640) never + /// touches this element's size at all — it stays a fixed 800x600 rect. The only way + /// retail's whole pre-world "flow" scene (background AND buttons AND listbox + /// together — "the background scales with the root") can still fill an arbitrary + /// window resolution edge-to-edge, with the generic sprite blit only ever able to + /// copy-or-tile, is that these screens render into a fixed, authored-size (800x600) + /// target and the WHOLE FRAME is stretched once at presentation — a step entirely + /// outside the UIRegion/Graphic::Draw sprite system. + /// + /// + /// + /// acdream's equivalent of that present-time stretch is + /// : while a fixed-canvas + /// screen (char select) is active, the WHOLE retained tree — this tile draw + /// included — is scaled uniformly at the renderer's quad chokepoint, with the + /// inverse applied to mouse input. Elements therefore keep their authored + /// canvas-space sizes here, and the tile formula stays exactly retail's: + /// inside the authored canvas an element never exceeds its media's native + /// span unless retail itself tiled it. + /// + /// protected override void OnDraw(UiRenderContext ctx) { if (MediaVisible && RuntimeImageTexture is uint runtimeTexture) @@ -221,7 +280,7 @@ public class UiDatElement : UiElement, IUiDatStateful 0f, 1f, 1f, - Vector4.One); + Tint); } DrawLabel(ctx); return; @@ -233,10 +292,14 @@ public class UiDatElement : UiElement, IUiDatStateful var (tex, tw, th) = _resolve(file); if (tex != 0 && tw != 0 && th != 0) { - // Normal → TILE at native size on both axes (UV-repeat; GL_REPEAT-wrapped UI - // texture), matching ImgTex::TileCSI. Overlay/Alphablend use the same blit (the - // sprite shader already alpha-blends). No Stretch mode exists in DrawModeType. - ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One); + // TILE at native size on both axes (UV-repeat; GL_REPEAT-wrapped + // UI texture) — retail's Graphic::Draw/Graphic::PutImage + // (0x00693b20/0x00693a30) copy-or-tile blit; NOT ImgTex::TileCSI, + // which is land-surface-only (corrected citation, see the class + // doc). Overlay/Alphablend use the same blit (the sprite shader + // already alpha-blends). No Stretch mode exists in DrawModeType; + // whole-canvas stretching happens at UiRoot.FixedCanvasSize. + ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Tint); } } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 8c21f9c0..da1e74b3 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -16,6 +16,7 @@ using AcDream.Core.Selection; using AcDream.Core.Spells; using AcDream.Runtime; using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Session; using AcDream.Content; using AcDream.Core.Input; using AcDream.UI.Abstractions; @@ -369,6 +370,53 @@ public sealed record KeyboardRuntimeBindings( InputDispatcher? Dispatcher, string KeyBindingsFilePath); +/// +/// Borrowed LA7b character-selection projection and its generation-capturing +/// typed command routes. App owns no roster, selection, operation, or error +/// mirror; an absent view means the current adapter has not bound (or has +/// already been released). +/// +/// +/// Campaign LA gate round 2 finding 1: retail's Exit button +/// (gmCharacterManagementUI::ListenToElementMessage@0x004ed5a0, +/// element offset 7 from the listbox base — id 0x100003A4) opens +/// MakeConfirmExitDialog@0x004ed250; on confirm +/// (RecvNotice_CloseDialog@0x004ed760 case 1) retail queues UI mode +/// 0x10000009 (gmEpilogueUI) rather than exiting immediately — +/// out of scope here. This is a plain host action, not a generation-gated +/// Runtime command: it is the SAME window-close path +/// GameplayWindowCommands/IGameplayWindowCommands.Close already +/// use for the in-world Escape fallback (d.Window.Close at +/// composition), so status events disconnected/exited still +/// fire through GameWindow.OnClosingCompleteShutdown. +/// +public sealed record CharacterSelectionRuntimeBindings( + Func View, + Func Highlight, + Func Enter, + Func RequestDelete, + Func ConfirmDelete, + Func Restore, + Func Cancel, + Action RequestExit, + /// + /// Campaign CC slice CC7: retail's Create button + /// (gmCharacterManagementUI::ListenToElementMessage @ 0x004ed5a0 + /// case 3 -> UIFramework::QueueUIMode(this, 0x1000000b), the + /// gmCharGenMainUI mode). Wired by + /// itself (it alone holds both the character-management and + /// character-creation controllers) to + /// CharacterCreationController?.Open() — resolved lazily so + /// mount order between the two screens does not matter. + /// when no chargen screen is mounted (e.g. a + /// headless bot's LiveCharacterSelector path, where + /// is also + /// null) — the button then behaves as a no-op click while its own + /// Enabled gate () + /// still reflects the real roster-vs-slot state. + /// + Action? RequestCreate = null); + public sealed record RetailUiRuntimeBindings( UiHost Host, RetailUiAssets Assets, @@ -395,7 +443,10 @@ public sealed record RetailUiRuntimeBindings( BufferedUiRegistry? Plugins, RetailUiPersistenceBindings? Persistence, RetailUiProbeBindings Probe, - KeyboardRuntimeBindings? Keyboard = null); + KeyboardRuntimeBindings? Keyboard = null, + CharacterSelectionRuntimeBindings? CharacterSelection = null, + // Campaign CC slice CC4: sibling of CharacterSelection above. + CharacterCreationRuntimeBindings? CharacterCreation = null); /// /// Composition owner for the production retained gameplay UI. GameWindow supplies @@ -417,6 +468,8 @@ public sealed class RetailUiRuntime : IDisposable private UiShortcutDigitGraphics? _shortcutDigitGraphics; private ItemCooldownUiController? _itemCooldownController; private VividTargetIndicatorController? _vividTargetIndicator; + private CharacterManagementUiMountCoordinator? _characterManagementMount; + private CharacterCreationUiMountCoordinator? _characterCreationMount; private IDisposable? _characterSheetSubscription; private ResourceShutdownTransaction? _shutdown; private bool _disposed; @@ -483,6 +536,10 @@ public sealed class RetailUiRuntime : IDisposable MountVendor(); MountSecureTrade(); MountItemCooldowns(); + ConfigureCharacterManagement(); + _characterManagementMount?.Tick(); + ConfigureCharacterCreation(); + _characterCreationMount?.Tick(); Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged; BindToolbarPanelButtons(); SyncToolbarWindowButtons(); @@ -577,6 +634,134 @@ public sealed class RetailUiRuntime : IDisposable public VendorUiController? VendorController { get; private set; } public OptionsPanelController? OptionsPanelController { get; private set; } public SocialPanelController? SocialPanelController { get; private set; } + internal CharacterManagementUiController? CharacterManagementController => + _characterManagementMount?.Controller; + internal CharacterCreationUiController? CharacterCreationController => + _characterCreationMount?.Controller; + + /// Campaign CC slice CC6b-MOUNT: the Appearance page's authored + /// viewport (0x100003bb) — null until the screen has mounted. + /// + /// + /// Fix round F8 correction: this is NOT the same shape as + /// — that one is a plain + /// { get; private set; } auto-property assigned exactly once, + /// eagerly and non-retryably, inside MountInventory() (itself + /// called synchronously from Initialize(); if it fails the whole + /// call throws and the WHOLE UI runtime fails to + /// construct — there is no partial-failure case where + /// stays null while the rest of + /// the runtime comes up). THIS property is computed-through specifically + /// BECAUSE its underlying mount, _characterCreationMount + /// (), is explicitly + /// retryable/idempotent — ticked once per frame via + /// until it succeeds, tolerating a DAT/resource read that isn't ready + /// yet without failing the rest of the UI. + /// reads this property EXACTLY ONCE, during the single synchronous + /// startup composition pass (GameWindow.OnLoad) — unlike the + /// coordinator's own per-frame Tick, that one-shot GPU-resource + /// composition pass is NOT retried, matching every other private + /// viewport binding in that same method (paperdoll, creature appraisal) + /// — see that call site's own comment for the full disposition. + /// + /// + internal UiViewport? ChargenPreviewViewportWidget => + CharacterCreationController?.AppearanceViewport; + + /// CC6b-MOUNT: the late-bound zoom/rotate control surface the + /// composition root assigns once the graphics backend exists. + internal AcDream.App.Rendering.IChargenPreviewControl? ChargenPreviewControl + { + get => CharacterCreationController?.AppearancePreviewControl; + set + { + if (CharacterCreationController is { } controller) + controller.AppearancePreviewControl = value; + } + } + + /// Campaign CC gate round 1 closeout (Group 1, R2-5): the same + /// late-bound pattern as above, for + /// the real color-wheel/swatch-color mechanism's three DAT-backed seams + /// — see 's + /// own doc comment. + internal AcDream.Core.CharGen.IChargenPalSetSource? ChargenPalSetSource + { + get => CharacterCreationController?.AppearancePalSetSource; + set + { + if (CharacterCreationController is { } controller) + controller.AppearancePalSetSource = value; + } + } + + internal AcDream.Core.CharGen.IChargenClothingTableSource? ChargenClothingTableSource + { + get => CharacterCreationController?.AppearanceClothingTableSource; + set + { + if (CharacterCreationController is { } controller) + controller.AppearanceClothingTableSource = value; + } + } + + internal AcDream.Core.CharGen.IChargenPaletteColorSource? ChargenPaletteColorSource + { + get => CharacterCreationController?.AppearancePaletteColorSource; + set + { + if (CharacterCreationController is { } controller) + controller.AppearancePaletteColorSource = value; + } + } + + /// R3-5/R3-6 (Campaign CC gate round 1 re-test 2): the fourth + /// late-bound seam, same pattern as the three above — see + /// 's + /// own doc comment. + internal AcDream.App.UI.Layout.IChargenSwatchTextureSource? ChargenSwatchTextureSource + { + get => CharacterCreationController?.AppearanceSwatchTextureSource; + set + { + if (CharacterCreationController is { } controller) + controller.AppearanceSwatchTextureSource = value; + } + } + + /// CC6b-MOUNT: whether the Appearance page (specifically) is + /// the one currently showing — false, safely, before the screen mounts. + /// + internal bool IsChargenPreviewPageVisible => + CharacterCreationController?.IsAppearancePageVisible ?? false; + + /// Campaign CC slice CC5: the Summary page's OWN authored + /// viewport (0x10000406) — same one-shot GPU-composition + /// disposition as (see that + /// property's own doc comment; AP-221 covers both). + internal UiViewport? SummaryPreviewViewportWidget => + CharacterCreationController?.SummaryViewport; + + /// Campaign CC slice CC5: the Summary preview's late-bound + /// control surface. No zoom/rotate buttons bind against it (retail's + /// Summary page has none) — the composition root assigns it purely so + /// + /// gets driven per-selection-change the same way the Appearance + /// preview's is. + internal AcDream.App.Rendering.IChargenPreviewControl? SummaryPreviewControl + { + get => CharacterCreationController?.SummaryPreviewControl; + set + { + if (CharacterCreationController is { } controller) + controller.SummaryPreviewControl = value; + } + } + + /// Campaign CC slice CC5: whether the Summary page + /// (specifically) is the one currently showing. + internal bool IsSummaryPreviewPageVisible => + CharacterCreationController?.IsSummaryPageVisible ?? false; public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings) { @@ -622,6 +807,10 @@ public sealed class RetailUiRuntime : IDisposable ExternalContainerController?.Tick(); SocialPanelController?.Tick(); _itemCooldownController?.Tick(); + _characterManagementMount?.Tick(); + CharacterManagementController?.Tick(); + _characterCreationMount?.Tick(); + CharacterCreationController?.Tick(); DialogFactory?.Tick(); Host.Tick(deltaSeconds); _automation?.Tick(deltaSeconds); @@ -788,6 +977,7 @@ public sealed class RetailUiRuntime : IDisposable { try { + CharacterManagementController?.ResetSession(); DialogFactory?.Reset(); } finally @@ -2437,7 +2627,12 @@ public sealed class RetailUiRuntime : IDisposable // default, installed at startup by the graphical host; // fixture/headless mounts leave the catalog empty and the // controller falls back to the static preset ladder. - availableResolutions: Rendering.DisplayModeCatalog.Resolutions, + // #407: the dropdown offers the WINDOWED union (hardware + // modes + static-ladder sizes that fit the desktop) — a + // windowed Size write needs no video mode, and remote/RDP + // displays advertise almost none. The fullscreen APPLY + // still validates against the hardware list only. + availableResolutions: Rendering.DisplayModeCatalog.WindowedResolutions, resolutionDefault: Rendering.DisplayModeCatalog.DesktopResolution); if (!configBound) Console.WriteLine("[UI] options panel: Config tab rows did not bind."); @@ -3030,13 +3225,29 @@ public sealed class RetailUiRuntime : IDisposable private void MountDialogFactory() { + if (DialogFactory is not null) + return; + uint layoutId; - lock (_bindings.Assets.DatLock) + try { - // DialogFactory::CreateDialog_ @ 0x00477AD0 resolves the shared - // catalog through GetDIDByEnum(2, 5). Each shown DialogInfo then - // creates a fresh type-specific root from that catalog. - layoutId = RetailDataIdResolver.Resolve(_bindings.Assets.Dats, 2u, 5u); + lock (_bindings.Assets.DatLock) + { + // DialogFactory::CreateDialog_ @ 0x00477AD0 resolves the shared + // catalog through GetDIDByEnum(2, 5). Each shown DialogInfo then + // creates a fresh type-specific root from that catalog. + layoutId = RetailDataIdResolver.Resolve( + _bindings.Assets.Dats, + 2u, + 5u); + } + } + catch (Exception error) + { + Console.WriteLine( + "[UI] retail dialog catalog will retry after resource " + + $"recovery: {error.Message}"); + return; } if (layoutId == 0u) @@ -3273,10 +3484,12 @@ public sealed class RetailUiRuntime : IDisposable _bindings.Assets.ResolveSprite, _bindings.Assets.Controls); Host.Root.AddChild(element); + _bindings.Plugins.CompleteMount(panel, Host.Root, element); Console.WriteLine($"[D.2b] plugin UI panel loaded: {panel.MarkupPath}"); } catch (Exception ex) { + _bindings.Plugins.FailMount(panel); Console.WriteLine($"[D.2b] plugin UI panel '{panel.MarkupPath}' failed to load: {ex.Message}"); } } @@ -3667,6 +3880,271 @@ public sealed class RetailUiRuntime : IDisposable "[M4] retail secure trade panel mounted from LayoutDesc 0x2100000D."); } + private void ConfigureCharacterManagement() + { + CharacterSelectionRuntimeBindings? bindings = + _bindings.CharacterSelection; + if (bindings is null || _characterManagementMount is not null) + return; + + // Campaign CC slice CC7: RetailUiRuntime is the one object holding + // BOTH controllers, so it supplies the cross-screen seam locally + // rather than routing it through the externally-composed bindings + // record (which is built before this runtime exists — see + // CharacterSelectionRuntimeBindings.RequestCreate's own doc + // comment). The lambda closes over `this` and reads + // CharacterCreationController per call, so it is safe even though + // ConfigureCharacterCreation() has not run yet at this point (see + // its call site immediately below this method's own caller). + _characterManagementMount = new CharacterManagementUiMountCoordinator( + Host.Root, + bindings with { RequestCreate = () => CharacterCreationController?.Open() }, + EnsureDialogFactory, + LoadCharacterManagementResources); + } + + private RetailDialogFactory? EnsureDialogFactory() + { + MountDialogFactory(); + return DialogFactory; + } + + private CharacterManagementUiMountResources? LoadCharacterManagementResources() + { + const uint stringTableId = 0x23000002u; + uint layoutId; + ImportedLayout? layout; + var strings = new DatStringResolver(_bindings.Assets.Dats); + lock (_bindings.Assets.DatLock) + { + // gmCharacterManagementUI's framework call passes enum + // 0x10000005 and category/table 5, then selects root 0x1000039A. + layoutId = RetailDataIdResolver.Resolve( + _bindings.Assets.Dats, + CharacterManagementUiController.RootEnum, + 5u); + layout = layoutId == 0u + ? null + : LayoutImporter.Import( + _bindings.Assets.Dats, + layoutId, + CharacterManagementUiController.RootElementId, + _bindings.Assets.ResolveSprite, + _bindings.Assets.DefaultFont, + _bindings.Assets.ResolveFont); + } + + if (layout is null) + { + Console.WriteLine( + "[UI] character management: enum-table-5 root could not be imported."); + return null; + } + + string? deleteResponse; + string? deleteConfirmationProbe; + string? pleaseWait; + string? enteringWorld; + string? confirmExit; + lock (_bindings.Assets.DatLock) + { + deleteConfirmationProbe = strings.ResolveTemplate( + stringTableId, + "ID_CharacterManagement_DeleteCharacterConfirmation", + new Dictionary + { + [DatStringResolver.PlayerVariable] = string.Empty, + }); + deleteResponse = ResolveCharacterManagementString( + strings, + stringTableId, + "ID_CharacterManagement_DeleteCharacterResponse"); + pleaseWait = ResolveCharacterManagementString( + strings, + stringTableId, + "ID_CharacterManagement_PleaseWait"); + enteringWorld = ResolveCharacterManagementString( + strings, + stringTableId, + "ID_Character_EnteringWorld"); + // Finding 1: MakeConfirmExitDialog@0x004ed250 resolves this via + // compute_str_hash("ID_CharacterManagement_ConfirmExit") against + // the same table-enum-0x10000002 -> 0x23000002 the other + // character-management dialogs already use. + confirmExit = ResolveCharacterManagementString( + strings, + stringTableId, + "ID_CharacterManagement_ConfirmExit"); + } + + if (deleteConfirmationProbe is null + || deleteResponse is null + || pleaseWait is null + || enteringWorld is null + || confirmExit is null) + { + Console.WriteLine( + "[UI] character management: required retail strings are unavailable."); + return null; + } + + UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId) + { + lock (_bindings.Assets.DatLock) + { + return LayoutImporter.Import( + _bindings.Assets.Dats, + templateLayoutId, + templateElementId, + _bindings.Assets.ResolveSprite, + _bindings.Assets.DefaultFont, + _bindings.Assets.ResolveFont)?.Root; + } + } + + string ComposeDeleteConfirmation(string characterName) + { + lock (_bindings.Assets.DatLock) + { + return NormalizeRetailNewlines(strings.ResolveTemplate( + stringTableId, + "ID_CharacterManagement_DeleteCharacterConfirmation", + new Dictionary + { + [DatStringResolver.PlayerVariable] = characterName, + })!); + } + } + + return new CharacterManagementUiMountResources( + layoutId, + layout, + ResolveTemplate, + new CharacterManagementUiController.DialogStrings( + ComposeDeleteConfirmation, + deleteResponse, + pleaseWait, + enteringWorld, + confirmExit)); + } + + private static string? ResolveCharacterManagementString( + DatStringResolver strings, + uint tableId, + string key) => + strings.Resolve(tableId, DatStringResolver.ComputeHash(key)) is { } value + ? NormalizeRetailNewlines(value) + : null; + + private static string NormalizeRetailNewlines(string value) => + value.Replace("\\n", "\n", StringComparison.Ordinal); + + private void ConfigureCharacterCreation() + { + CharacterCreationRuntimeBindings? bindings = _bindings.CharacterCreation; + if (bindings is null || _characterCreationMount is not null) + return; + + _characterCreationMount = new CharacterCreationUiMountCoordinator( + Host.Root, + bindings, + EnsureDialogFactory, + LoadCharacterCreationResources); + } + + private CharacterCreationUiMountResources? LoadCharacterCreationResources() + { + const uint stringTableId = 0x23000002u; + uint layoutId; + ImportedLayout? layout; + var strings = new DatStringResolver(_bindings.Assets.Dats); + lock (_bindings.Assets.DatLock) + { + // gmCharGenMainUI's framework registration passes enum + // 0x10000039 and category/table 5, then selects root 0x100003CC. + layoutId = RetailDataIdResolver.Resolve( + _bindings.Assets.Dats, + CharacterCreationUiController.RootEnum, + 5u); + layout = layoutId == 0u + ? null + : LayoutImporter.Import( + _bindings.Assets.Dats, + layoutId, + CharacterCreationUiController.RootElementId, + _bindings.Assets.ResolveSprite, + _bindings.Assets.DefaultFont, + _bindings.Assets.ResolveFont); + } + + if (layout is null) + { + Console.WriteLine( + "[UI] character creation: enum-table-5 root could not be imported."); + return null; + } + + string? exitWarning; + string? noNameWarning; + string? creditWarning; + string? randomizeWarning; + string? nameTooLong; + lock (_bindings.Assets.DatLock) + { + exitWarning = ResolveCharacterManagementString( + strings, + stringTableId, + "ID_CharGen_ExitWarning"); + noNameWarning = ResolveCharacterManagementString( + strings, + stringTableId, + "ID_CharGen_NoNameWarning"); + creditWarning = ResolveCharacterManagementString( + strings, + stringTableId, + "ID_CharGen_CreditWarning"); + randomizeWarning = ResolveCharacterManagementString( + strings, + stringTableId, + "ID_CharGen_RandomizeWarning"); + nameTooLong = ResolveCharacterManagementString( + strings, + stringTableId, + "ID_CharGen_NameTooLong"); + } + if (exitWarning is null + || noNameWarning is null + || creditWarning is null + || randomizeWarning is null + || nameTooLong is null) + { + Console.WriteLine( + "[UI] character creation: required retail strings are unavailable."); + return null; + } + + UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId) + { + lock (_bindings.Assets.DatLock) + { + return LayoutImporter.Import( + _bindings.Assets.Dats, + templateLayoutId, + templateElementId, + _bindings.Assets.ResolveSprite, + _bindings.Assets.DefaultFont, + _bindings.Assets.ResolveFont)?.Root; + } + } + + return new CharacterCreationUiMountResources( + layoutId, + layout, + ResolveTemplate, + new CharacterCreationUiController.DialogStrings( + exitWarning, noNameWarning, creditWarning, randomizeWarning, nameTooLong)); + } + private void MountItemCooldowns() { ItemCooldownAssets? assets; @@ -3710,7 +4188,12 @@ public sealed class RetailUiRuntime : IDisposable } }, () => _itemConfirmationController?.Dispose(), - () => _gameplayConfirmationController?.Dispose(), + () => + { + _characterManagementMount?.Dispose(); + _characterCreationMount?.Dispose(); + _gameplayConfirmationController?.Dispose(); + }, () => DialogFactory?.Dispose(), _panelUi.Dispose, Host.Dispose); diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index 046f2973..45166aba 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -37,6 +37,9 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful private readonly FaceSegment[] _faceSegments; private readonly Func _resolve; private readonly HashSet _availableStates = new(); + private readonly bool _hasCustomSelectionPair; + private IReadOnlyDictionary? _stateLabelColors; + private IReadOnlyDictionary? _stateLabelOutlines; private bool _pressed; private bool _pointerOver; private bool _selected; @@ -49,6 +52,13 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful /// Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize). public Action? OnClick { get; set; } + /// + /// Optional left-button double-click handler. Null preserves the existing + /// bubbling behavior; character-management row template 0x100003A5 opts in + /// for retail's element message 0x1A (activate the selected character). + /// + public Action? OnDoubleClick { get; set; } + /// /// Optional right-click handler (Campaign OP slice OP8's Configure Keyboard /// screen: right-click a bound key button to erase that one binding — @@ -143,6 +153,50 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful /// public uint? FaceFileOverride { get; set; } + /// + /// Campaign CC gate round 1 closeout (Group 1, R2-5): per-instance + /// multiplicative sprite tint, threaded into every + /// call this class makes (main face, face-segment, drag-acceptance + /// overlay) — retail's own SurfaceWindow::BlitAndColor(..., + /// Blit_Multiply, color). Default (white, + /// full alpha) leaves every DrawSprite call byte-identical to before + /// this property existed; only a caller that explicitly sets a + /// non-identity tint (e.g. 's + /// color-wheel swatches) changes what draws. + /// + public Vector4 Tint { get; set; } = Vector4.One; + + /// + /// R3-5 (Campaign CC gate round 1 re-test 2): optional resolver + /// returning a PRE-BAKED, already color-key-recolored texture handle + /// (from + /// or equivalent), drawn UNTINTED (1:1, no UV repeat) INSTEAD of the + /// ordinary /ActiveFile sprite. + /// Retail's own gmCGAppearancePage::DoColorSpots @0x0047d850 + /// does NOT multiply-tint the swatch's authored ring+spot sprite (a + /// multiply of a target color against BLACK — the spot template's own + /// placeholder fill, live-DAT-pixel-confirmed — stays black regardless + /// of the tint, and multiplying the ring's own non-black border pixels + /// shifts their hue/brightness, corrupting them). Retail instead calls + /// SurfaceWindow::ReplaceColor: build a fresh composited surface + /// once, blit the spot template onto it, then swap every EXACT-black + /// pixel for the swatch's real color — the ring border (never black) + /// is untouched. This property is that same mechanism's C# seam. + /// itself is left completely unchanged in meaning + /// and is STILL the value callers set to communicate "this button's + /// color is X" (existing callers/tests that only read + /// are unaffected) — this resolver is a SEPARATE + /// decision (deliberately not fed by : a caller may + /// need to distinguish more states — e.g. "beyond count, show the + /// blocked art" versus "no color data yet, show nothing" — than one + /// Vector4 can encode) that only changes what OnDraw does when + /// non-null: consult it for a texture instead of directly multiplying + /// the authored sprite. Null (default, every pre-existing button) + /// preserves the exact prior FaceFileOverride/ActiveFile + + /// multiply-Tint draw. + /// + public Func? ColorKeyFaceResolver { get; set; } + /// Additional left inset for left-aligned labels. public float LabelOffsetX { get; set; } = 3f; @@ -150,8 +204,74 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful /// Left for the paperdoll "Slots" caption that sits at the left edge, before the slots. public LabelAlignment LabelAlign { get; set; } = LabelAlignment.Center; - /// Label horizontal alignment options. - public enum LabelAlignment { Center, Left } + /// + /// GF-11c (Campaign CC gate round 1 Batch B): optional authored label + /// rectangle, LOCAL to this button. When a caption is LIFTED from a + /// DISTINCT Type-12 child that carries its own independent rect (e.g. + /// the Town page's per-marker name label, positioned below/beside its + /// marker rather than immediately right of it), + /// draws the label within THIS box using its own authored geometry + /// instead of the FaceLeft-derived offset / full-button-width centering + /// the ordinary case uses (label authored directly on the button, right + /// beside a single-purpose face segment — the heritage/template/Face- + /// Clothes row family, where the current face-relative math is already + /// correct). Null (default, every pre-existing button) preserves the + /// EXACT prior draw math — still adds + /// to the button's own local origin, and + /// still centers within the whole + /// button width/height. + /// + public (float X, float Y, float Width, float Height)? LabelBox { get; set; } + + /// + /// GF-4a (Campaign CC gate round 1 Batch C): optional secondary VALUE + /// text, coexisting with (the authored CAPTION). + /// Retail's chargen display buttons (Attribute/Skill Credits, Health, + /// Stamina, Mana — 0x100003e2-e5, 0x100003f9) author the + /// caption directly as this element's own dat property 0x17 + /// AND carry a SEPARATE, media-less Type-12 child for the live value + /// (gmCGProfessionPage::InitializePage @0x00482f90-0x00483062, + /// gmCGSkillsPage::InitializePage @0x00481e1c) — + /// consumes ALL of its dat children + /// (), which used to mean a page + /// controller had nowhere faithful to put the value except + /// overwriting itself, destroying the caption. + /// now surfaces that + /// child's geometry/font/color here instead. Null (default) draws + /// nothing extra — every pre-existing button that only ever wrote + /// is unaffected. + /// + public string? ValueLabel { get; set; } + + /// Dat font for . + public UiDatFont? ValueFont { get; set; } + + /// Color for (default white). + public Vector4 ValueColor { get; set; } = Vector4.One; + + /// Authored rectangle for , LOCAL to + /// this button — the lifted value child's own rect + /// ( sets this). Null + /// (no value child found) means is never set + /// either, so this is never read in that case. + public (float X, float Y, float Width, float Height)? ValueBox { get; set; } + + /// Horizontal alignment of within + /// — the lifted child's own authored justify. + public LabelAlignment ValueAlign { get; set; } = LabelAlignment.Center; + + /// + /// Label horizontal alignment options. (R4-1, Campaign + /// CC gate round 1 re-test 3) is ValueLabel-only today — every value + /// child on the chargen credit-display family (0x100002f1/0x100002f3) + /// authors dat HJustify Right (raw 3/5), decomp-confirmed by + /// UIElement_Text::CalcJustification @0x00467260's + /// ecx_5==3||5 branch (edi = availWidth - textWidth, i.e. + /// flush to the box's own far edge) — distinct from Center's halved + /// offset. never authors Right today so no + /// existing switch over it needs a new arm. + /// + public enum LabelAlignment { Center, Left, Right } public bool ToggleBehavior { get; } public bool RolloverEnabled { get; } @@ -323,6 +443,21 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful foreach (FaceSegment segment in _faceSegments) AddAvailableStates(segment.Info); + // Campaign CC gate round 1 Batch B (GF-1/GF-8): retail's custom + // "Unselected"/"Selected" radio-selection state pair + // (RetailUiStateIds.Unselected/Selected, 0x10000016/0x10000017) is + // authored as STATE DESCRIPTORS whose names UiButtonStateMachine's + // Normal/Highlight machine doesn't recognize — the standard + // AddAvailableStates loop above never admits them, so the ordinary + // RequestedState()-driven UpdateVisualState can never select them + // (measured: Selected=true committed nothing against the installed + // dat before this fix). HasStateMedia already checks the same media + // presence (face-segment child OR the button's own StateMedia) used + // everywhere else in this class, so this reuses that exact + // detection rather than adding a new one. + _hasCustomSelectionPair = HasStateMedia(RetailUiStateIds.StateName(RetailUiStateIds.Unselected)) + && HasStateMedia(RetailUiStateIds.StateName(RetailUiStateIds.Selected)); + ToggleBehavior = info.TryGetEffectiveBool(0x0Bu, out bool toggle) && toggle; RolloverEnabled = info.TryGetEffectiveBool(0x13u, out bool rollover) && rollover; HotClickEnabled = info.TryGetEffectiveBool(0x0Fu, out bool hotClick) && hotClick; @@ -375,6 +510,22 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful foreach (FaceSegment segment in _faceSegments) DrawFace(ctx, ActiveFile(segment.Info), segment.Rect(Width, Height)); } + else if (ColorKeyFaceResolver is { } colorKeyResolver) + { + // R3-5: a pre-baked, already-recolored texture (see this + // property's own doc) — drawn UNTINTED and 1:1 (no UV repeat; + // the baked bitmap is uploaded at its own native size, which + // for the chargen swatches equals the button's own authored + // rect, live-DAT-measured). + uint bakedTexture = colorKeyResolver(); + if (bakedTexture != 0) + { + float faceWidth = FaceWidth > 0f ? FaceWidth : Width; + float faceHeight = FaceHeight > 0f ? FaceHeight : Height; + ctx.DrawSprite(bakedTexture, FaceLeft, FaceTop, faceWidth, faceHeight, + 0f, 0f, 1f, 1f, Vector4.One); + } + } else { uint file = FaceFileOverride ?? ActiveFile(_mediaInfo); @@ -388,18 +539,58 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful float faceWidth = FaceWidth > 0f ? FaceWidth : Width; float faceHeight = FaceHeight > 0f ? FaceHeight : Height; ctx.DrawSprite(tex, FaceLeft, FaceTop, faceWidth, faceHeight, - 0, 0, faceWidth / tw, faceHeight / th, Vector4.One); + 0, 0, faceWidth / tw, faceHeight / th, Tint); } } } if (Label is { Length: > 0 } label && LabelFont is { } lf) { - float tx = LabelAlign == LabelAlignment.Left - ? LabelOffsetX - : (Width - lf.MeasureWidth(label)) * 0.5f; // centered (default) - float ty = (Height - lf.LineHeight) * 0.5f; - ctx.DrawStringDat(lf, label, tx, ty, LabelColor, Outline, OutlineColor); + // GF-11c: LabelBox null (every pre-existing button) reduces boxX/ + // boxY to 0 and boxWidth/boxHeight to the button's own Width/ + // Height — byte-identical to the prior unconditional math. + float boxX = LabelBox?.X ?? 0f; + float boxY = LabelBox?.Y ?? 0f; + float boxWidth = LabelBox?.Width ?? Width; + float boxHeight = LabelBox?.Height ?? Height; + + // R2-2/R2-3 (Campaign CC gate round 1 Batch E) + R3-2 correction + // (re-test 2): when this button ALSO carries a coexisting + // ValueLabel (GF-4a's own-caption + separate value slot — the + // Profession attribute/health/stamina/mana credits buttons, the + // Skills credits button), boxWidth still narrows to stop before + // the value's authored rect for the (currently unused, since + // every known ValueBox button is Left-aligned) Center-tx + // formula and the explicit-newline clip rect below — see + // DrawBlockLabel's own doc for why this no longer gates + // WHETHER a single-line caption wraps or clips (R3-2: it never + // did in retail — live-DAT-measured, "Available Skill Credits" + // fits the button's own full 231px width with room to spare). + if (ValueBox is { X: var valueBoxX } && valueBoxX > boxX) + boxWidth = MathF.Min(boxWidth, valueBoxX - boxX); + + DrawBlockLabel(ctx, label, lf, LabelColor, boxX, boxY, boxWidth, boxHeight, LabelAlign, LabelOffsetX); + } + + if (ValueLabel is { Length: > 0 } value && ValueFont is { } vf) + { + float boxX = ValueBox?.X ?? 0f; + float boxY = ValueBox?.Y ?? 0f; + float boxWidth = ValueBox?.Width ?? Width; + float boxHeight = ValueBox?.Height ?? Height; + float valueWidth = vf.MeasureWidth(value); + // R4-1: Right mirrors CalcJustification's own far-edge formula + // (box's own right edge minus the measured text width, no + // decorative inset — the decomp's Right branch adds none either, + // and this box carries no threaded marginR of its own). + float vx = ValueAlign switch + { + LabelAlignment.Left => boxX + LabelOffsetX, + LabelAlignment.Right => boxX + boxWidth - valueWidth, + _ => boxX + (boxWidth - valueWidth) * 0.5f, + }; + float vy = boxY + (boxHeight - vf.LineHeight) * 0.5f; + ctx.DrawStringDat(vf, value, vx, vy, ValueColor, Outline, OutlineColor); } uint dragSprite = _itemDragAcceptance switch @@ -412,10 +603,154 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful { var (tex, _, _) = _resolve(dragSprite); if (tex != 0) - ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); + ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Tint); } } + /// + /// R2-2 (Campaign CC gate round 1 Batch E) + R3-1/R3-2 (re-test 2 + /// correction): retail's UIElement_Button IS a + /// UIElement_Text (struct UIElement_Button : UIElement_Text, + /// acclient.h) — a caption that carries an authored newline + /// (already normalized to a real '\n' by + /// 's shared + /// ResolveAuthoredString) lays out as multiple stacked lines. A + /// single line that already fits draws with byte-identical geometry to + /// the pre-Batch-E unconditional one-line math (same centered-block Y, + /// same tx formula). + /// + /// Batch E ALSO auto-wrapped a paragraph that doesn't fit + /// via — re- + /// derived at re-test 2 (R3-1 "Coordination"/R3-2 "Available Skill + /// Credits") as the wrong shape and REMOVED: live-DAT-probed, the + /// Coordination slider label (0x100002ed) authors OneLine= + /// true (dat property 0x20) and the Skills credits button + /// (0x100003f9) authors OneLine=false yet BOTH render one + /// line in retail. Tracing GlyphList::Recalculate + /// @0x00473800's per-glyph loop: the ENTIRE width-triggered break + /// decision (and, separately, the explicit-newline break) sits behind + /// one gate, if (arg3 == 0) where arg3 is the SAME + /// OneLine boolean passed in from + /// UIElement_Text::ResizeToPaper/InqSize — i.e. a + /// caption's width is measured against its own FULL element rect (minus + /// margins), never against a sibling/child element's geometry; nothing + /// in the decomp confines a caption's wrap width to stop before another + /// element's rect. The 193px "Available Skill Credits" caption fits the + /// button's own full 231px width (live-DAT-measured) with room to + /// spare — it never needed to wrap at all. So: split ONLY on the + /// explicit \n (never invoke ) — a + /// strict superset of the pre-Batch-E single-line draw for every + /// caption that was already correct, and the exact shape "Attribute\n + /// Credits" (an authored break) still needs. + /// + /// + /// R3-2 deliberately does NOT clip a single (unwrapped) line to + /// either, even when the caller narrowed it + /// via a coexisting — clipping would cut the + /// caption's own tail off mid-word, which contradicts "retail is ONE + /// line" just as much as wrapping does (a viewer would call that + /// truncated, not "one line"). The 193px-in-231px Skills-credits + /// geometry means the caption's rendered span (x≈3 to x≈196) does + /// overlap the value's own rect (x=116 to x=150, live-DAT-measured) in + /// principle — Batch E's own diagnosis of the ORIGINAL R2-2/R2-3 + /// "24dits"/"Credit0Credits" reports. That overlap is NOT re-solved + /// here: this fix only removes the false wrap this specific finding + /// (R3-2) reported, and inventing an unevidenced clip boundary to + /// pre-empt a DIFFERENT, not-currently-reported symptom would be + /// exactly the guessing this project's workflow forbids. Flagged in + /// the findings doc for the user's own re-check once the wrap is gone. + /// + /// + private void DrawBlockLabel( + UiRenderContext ctx, + string text, + UiDatFont font, + Vector4 color, + float boxX, + float boxY, + float boxWidth, + float boxHeight, + LabelAlignment align, + float leftOffset) + { + IReadOnlyList<(string Text, float X, float Y)> lines = WrapBlockLines( + text, font.MeasureWidth, font.LineHeight, + boxX, boxY, boxWidth, boxHeight, align, leftOffset); + + // A multi-line result (an authored '\n') clips to its own box — the + // button's normal draw has no ambient clip, and an oversized + // wrapped caption (e.g. the Skills credits button's own tight 28px + // height) should be cut off at the box edge rather than spill into + // whatever sits below the button, matching every other clipped + // Type-12 text box in this codebase (UiText.DrawText's own + // PushClip). Single-line captions — the overwhelming majority, + // and (post-R3-2) EVERY caption with no authored newline — never + // pay this cost; see this method's own doc for why a single line + // is deliberately left unclipped even when boxWidth was narrowed. + bool clip = lines.Count > 1; + if (clip) + ctx.PushClip(boxX, boxY, boxWidth, boxHeight); + try + { + foreach ((string line, float tx, float ty) in lines) + ctx.DrawStringDat(font, line, tx, ty, color, Outline, OutlineColor); + } + finally + { + if (clip) + ctx.PopClip(); + } + } + + /// + /// Pure geometry half of — split ONLY on an + /// authored explicit '\n', then block-centered vertically within + /// . Pulled out as a static/pure method + /// (same shape as ) so the geometry + /// is unit-testable without a font atlas or draw context — + /// takes the place of + /// . + /// + /// R3-1/R3-2 (re-test 2): deliberately does NOT width-wrap a paragraph + /// that overflows — see + /// 's own doc for the decomp citation + /// (GlyphList::Recalculate's width-triggered break sits behind + /// the SAME OneLine gate as the explicit-newline break, and + /// retail never confines a caption's wrap width to a sibling element's + /// rect). A paragraph that overflows still draws as one line, unclipped + /// by width — matching every plain (no authored \n) button + /// caption in retail, which is never observed to wrap. + /// + /// + internal static IReadOnlyList<(string Text, float X, float Y)> WrapBlockLines( + string text, + Func measureWidth, + float lineHeight, + float boxX, + float boxY, + float boxWidth, + float boxHeight, + LabelAlignment align, + float leftOffset) + { + string[] lines = text.Split('\n'); + + float totalHeight = lines.Length * lineHeight; + float startY = boxY + (boxHeight - totalHeight) * 0.5f; + + var result = new List<(string, float, float)>(lines.Length); + for (int i = 0; i < lines.Length; i++) + { + string line = lines[i]; + float tx = align == LabelAlignment.Left + ? boxX + leftOffset + : boxX + (boxWidth - measureWidth(line)) * 0.5f; + float ty = startY + i * lineHeight; + result.Add((line, tx, ty)); + } + return result; + } + private void DrawFace(UiRenderContext ctx, uint file, UiPixelRect rect) { if (file == 0 || rect.Width <= 0 || rect.Height <= 0) @@ -428,7 +763,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful // first reflowed by its own four-edge retail layout policy. ctx.DrawSprite(texture, rect.X0, rect.Y0, rect.Width, rect.Height, 0f, 0f, (float)rect.Width / textureWidth, (float)rect.Height / textureHeight, - Vector4.One); + Tint); } private void AddAvailableStates(ElementInfo mediaInfo) @@ -551,6 +886,11 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful OnClick?.Invoke(); OnClickAt?.Invoke(e.Data1, e.Data2); return OnClick is not null || OnClickAt is not null; + case UiEventType.DoubleClick: + if (OnDoubleClick is null) return false; + if (!Enabled) return true; + OnDoubleClick.Invoke(); + return true; case UiEventType.RightClick: // S6 (2026-08-11 review): unlike Click (whose swallow-when- // disabled is pre-existing, harmless-by-construction behavior @@ -626,13 +966,77 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful private void UpdateVisualState() { - uint requested = UiButtonStateMachine.RequestedState(new UiButtonVisualInput( - Disabled: !Enabled, - Selected: _selected, - RolloverEnabled: RolloverEnabled, - Pressed: _pressed, - PointerOver: _pointerOver)); - if (_availableStates.Contains(requested)) + uint requested = ComputeRequestedStateId(); + if (_hasCustomSelectionPair) + { + // gmCGHeritagePage::Update @0x00483219-0x0048372D (and the + // mirrored template/sub-tab/gender call sites): retail sets + // this pair directly by SELECTION, not through the ordinary + // Normal/Highlight/rollover/pressed machine — these buttons + // never author rollover or pressed media for the pair, so + // there is nothing faithful to compute beyond selected-or-not. + ActiveState = RetailUiStateIds.StateName(requested); + } + else if (_availableStates.Contains(requested)) + { ActiveState = UiButtonStateMachine.StateName(requested); + } + + // AP-222: apply the per-state label style off the REQUESTED id, not + // the (possibly art-gated) committed ActiveState — retail's own + // SetState(6) commits the state's PROPERTIES (including text color) + // unconditionally; only the SPRITE draw silently no-ops when a + // state has no media (this class's own #382 comment on + // TrySetRetailState documents the same distinction). The + // Appearance spins' current-part highlight is exactly this case: + // _availableStates never contains Highlight (their arrow face + // segments carry no Highlight art), so ActiveState stays "Normal" + // forever, but the spin's OWN label color must still swap. + ApplyPerStateLabelStyle(requested); + } + + private uint ComputeRequestedStateId() + => _hasCustomSelectionPair + ? (_selected ? RetailUiStateIds.Selected : RetailUiStateIds.Unselected) + : UiButtonStateMachine.RequestedState(new UiButtonVisualInput( + Disabled: !Enabled, + Selected: _selected, + RolloverEnabled: RolloverEnabled, + Pressed: _pressed, + PointerOver: _pointerOver)); + + /// + /// AP-222 / GF-11b (Campaign CC gate round 1 Batch B): optional per- + /// RETAIL-STATE label color/outline override, additive over the single + /// default / lifted once at + /// construction. Set by ONLY when + /// the authored dat genuinely carries more than one distinct value + /// across this button's (or its lifted caption child's) own states — + /// e.g. the Appearance spins' Highlight-state gold brightening + /// (dat properties 0x1B/0x21, live-DAT-measured + /// 218,167,85 -> 255,221,131 plus outline off -> on) or the Town + /// buttons' Normal-to-white caption swap (218,167,85 -> 255,255,255). + /// A button with a single authored color (the overwhelming majority) + /// never calls this, so / + /// keep behaving exactly as before — including every existing external + /// post-construction assignment (e.g. ChatWindowController's Send + /// caption, PaperdollController's Slots label), none of which + /// author a second distinct per-state color. + /// + internal void SetPerStateLabelStyle( + IReadOnlyDictionary? colors, + IReadOnlyDictionary? outlines) + { + _stateLabelColors = colors; + _stateLabelOutlines = outlines; + ApplyPerStateLabelStyle(ComputeRequestedStateId()); + } + + private void ApplyPerStateLabelStyle(uint requestedStateId) + { + if (_stateLabelColors is { } colors && colors.TryGetValue(requestedStateId, out Vector4 color)) + LabelColor = color; + if (_stateLabelOutlines is { } outlines && outlines.TryGetValue(requestedStateId, out bool outline)) + Outline = outline; } } diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index cc7fcbfe..668d51fc 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -57,6 +57,19 @@ public abstract class UiElement /// Human-readable name for debugging / FindByName. public string? Name { get; init; } + /// + /// GF-13 (Campaign CC gate round 1, Batch A): mirrors + /// ElementInfo.Invisible (dat property 0x3B) — a PURE DATA + /// PASSTHROUGH set by LayoutImporter.BuildWidget at construction. + /// The shared importer does NOT act on this flag (1,083 elements author + /// it client-wide, docs/ISSUES.md #408); it exists only so a screen that + /// owns its own mounted subtree can honor it explicitly, the way + /// CharacterCreationUiController does for the chargen screen + /// (register AP-230). Reading this never changes by + /// itself. + /// + public bool AuthoredInvisible { get; internal set; } + private readonly Dictionary _stateCursors = new(); /// Retail MediaDescCursor entries keyed by UIStateId.ToString(), or "" for DirectState. diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index 6ca8c472..a9374c8a 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Numerics; namespace AcDream.App.UI; @@ -32,6 +33,134 @@ public sealed class UiRoot : UiElement /// Single owner for named retained-window lifecycle and raise policy. public RetailWindowManager WindowManager { get; } + /// + /// Campaign LA gate round 2 (register AD-98): when set, the retained tree + /// is laid out in this fixed authored canvas (the char-select screen's + /// 800×600) and the whole tree — widgets, glyphs, art — is stretched to + /// the window as one unit, matching retail's present-time frame stretch + /// for fixed-canvas pre-world screens. Draw applies the scale at the + /// renderer's quad chokepoint; the mouse entry points apply the inverse, + /// so / and every hit test live + /// in canvas space. Null (the in-world default) is native 1:1. + /// + /// + /// Campaign CC slice CC4 review-fix round R1 (2026-08-15): this raw + /// setter remains public for tests that exercise the scale/mouse- + /// mapping math in isolation (UiRootFixedCanvasTests), but + /// PRODUCTION code must go through / + /// instead of writing this property + /// directly. Two fixed-canvas screens can be active at once + /// (character-management underneath character-creation) and a raw + /// write from either one is a last-writer-wins race with no owner — + /// the F1 fix's own Close() null wiped the OTHER screen's still- + /// active canvas out from under it (see AD-98). + /// + /// + public Vector2? FixedCanvasSize { get; set; } + + /// Screens currently declaring a fixed canvas, keyed by owner + /// (see ). + private readonly Dictionary _fixedCanvasDeclarations = new(); + + /// + /// Declares that wants the retained tree laid + /// out in while it is active. This is the single + /// arbiter for : multiple owners may declare + /// concurrently (character-management stays declared while character- + /// creation is also open on top of it), and the effective + /// is the shared declaration set's value. + /// Every current declarer must agree on the size — a mismatched second + /// declaration throws rather than silently overwriting the first + /// (Campaign CC CC4 review-fix round R1, 2026-08-15; see + /// docs/architecture/retail-divergence-register.md AD-98). Pair + /// every call with on the SAME owner at + /// deactivate/close/dispose. + /// + public void DeclareFixedCanvas(object owner, Vector2 size) + { + ArgumentNullException.ThrowIfNull(owner); + if (_fixedCanvasDeclarations.TryGetValue(owner, out Vector2 existing)) + { + if (existing == size) + return; // idempotent re-declare (e.g. a re-ticked activation edge) + throw new InvalidOperationException( + $"UiRoot.DeclareFixedCanvas: owner {owner} re-declared a different " + + $"canvas ({existing} -> {size}) without revoking first."); + } + + foreach (Vector2 declared in _fixedCanvasDeclarations.Values) + { + if (declared != size) + { + throw new InvalidOperationException( + $"UiRoot.DeclareFixedCanvas: owner {owner} declared {size} but " + + $"another active owner already declared {declared} — every " + + "concurrently-active fixed-canvas screen must author the SAME " + + "canvas size (see AD-98)."); + } + } + + _fixedCanvasDeclarations[owner] = size; + FixedCanvasSize = size; + } + + /// Revokes 's declaration from + /// . + /// becomes null only once EVERY declarer has revoked; while another + /// owner is still declared, it stays set to that shared value. A + /// revoke from an owner that never declared (or already revoked) is a + /// no-op, matching the idempotent shutdown paths (Deactivate + /// AND Dispose can both revoke the same owner). + public void RevokeFixedCanvas(object owner) + { + ArgumentNullException.ThrowIfNull(owner); + if (!_fixedCanvasDeclarations.Remove(owner)) + return; + + if (_fixedCanvasDeclarations.Count == 0) + { + FixedCanvasSize = null; + return; + } + + foreach (Vector2 declared in _fixedCanvasDeclarations.Values) + { + FixedCanvasSize = declared; + break; + } + } + + /// + /// The coordinate space the retained tree currently lays out in: the fixed + /// authored canvas while one is active, else the window itself. Anything + /// that positions against "the screen" (dialog centering, full-screen + /// scrims) must use THIS — the gate-round-2 exit dialog centered against + /// the 1920px window while the tree lived in the 800px canvas, landing far + /// right of the visible screen center. + /// + public Vector2 EffectiveCanvasSize => + FixedCanvasSize is { X: > 0f, Y: > 0f } canvas + ? canvas + : new Vector2(Width, Height); + + /// Window→canvas stretch factor; One when no fixed canvas is set. + public Vector2 CanvasScale => + FixedCanvasSize is { X: > 0f, Y: > 0f } canvas && Width > 0f && Height > 0f + ? new Vector2(Width / canvas.X, Height / canvas.Y) + : Vector2.One; + + private (int x, int y) MapWindowToCanvas(int x, int y) + { + // Truncate, not round (batch review F6): rounding maps the window's + // last column/row one past the canvas's last valid coordinate + // (1919/2.4 → 800, past 799), creating a 1px dead band at the far + // right/bottom edge. Truncation maps 0..1919 onto 0..799 exactly. + Vector2 scale = CanvasScale; + return scale == Vector2.One + ? (x, y) + : ((int)(x / scale.X), (int)(y / scale.Y)); + } + // ── Device-level state ─────────────────────────────────────────────── public int MouseX { get; private set; } public int MouseY { get; private set; } @@ -370,6 +499,21 @@ public sealed class UiRoot : UiElement } public void Draw(UiRenderContext ctx) + { + // AD-98 fixed-canvas stretch: scope the renderer's canvas scale to + // exactly this tree's draws (world-space HUD stays native). + ctx.TextRenderer.CanvasScale = CanvasScale; + try + { + DrawCore(ctx); + } + finally + { + ctx.TextRenderer.CanvasScale = Vector2.One; + } + } + + private void DrawCore(UiRenderContext ctx) { // Render children (panels) sorted by z-order — modal last so it // sits on top. @@ -401,6 +545,7 @@ public sealed class UiRoot : UiElement public void OnMouseMove(int x, int y) { + (x, y) = MapWindowToCanvas(x, y); int dx = x - MouseX; int dy = y - MouseY; MouseX = x; @@ -552,6 +697,7 @@ public sealed class UiRoot : UiElement public void OnMouseDown(UiMouseButton btn, int x, int y, uint flags = 0) { + (x, y) = MapWindowToCanvas(x, y); MouseX = x; MouseY = y; UpdateButtonFlag(btn, down: true); _pressX = x; _pressY = y; @@ -707,6 +853,7 @@ public sealed class UiRoot : UiElement public void OnMouseUp(UiMouseButton btn, int x, int y, uint flags = 0) { + (x, y) = MapWindowToCanvas(x, y); MouseX = x; MouseY = y; UpdateButtonFlag(btn, down: false); diff --git a/src/AcDream.App/UI/UiScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs index 24b17201..3892bdac 100644 --- a/src/AcDream.App/UI/UiScrollbar.cs +++ b/src/AcDream.App/UI/UiScrollbar.cs @@ -249,6 +249,12 @@ public sealed class UiScrollbar : UiElement return; } + if (ScalarChanged is not null) + { + DrawVerticalScalar(ctx, resolve); + return; + } + if (Model is not { } m) return; // Track background — TILED vertically (retail DrawMode=Normal). The native track @@ -280,11 +286,60 @@ public sealed class UiScrollbar : UiElement } else { - DrawTiled(ctx, resolve, ThumbSprite, 0f, ty, Width, th); + // R4-2 (Campaign CC gate round 1 re-test 3): the single- + // sprite thumb shape (no top/bottom caps — see this method's + // own doc, the R3-4/R3-7 fallback family: Skills listbox + // 0x100003f8, Summary overview 0x10000401, Summary how-to + // 0x100002e7) is a small fixed "diamond" marker graphic, NOT + // a stretchy bar — DrawTiled's UV-repeat was drawing it + // MULTIPLE times to fill the track-proportional thumb rect + // (~9 repeats on Summary's overview bar, ~2 on Skills, per + // the live capture). DrawThumbMarker draws exactly ONE + // instance at its own native size. + DrawThumbMarker(ctx, resolve, ThumbSprite, 0f, ty, Width, th, vertical: true); } } } + /// + /// R4-2 (Campaign CC gate round 1 re-test 3): draws ONE instance of a + /// single-sprite scrollbar thumb at its own native size, centered + /// within the computed thumb rect ('s own + /// decomp-cited UIElement_Scrollbar::UpdateLayout @0x4710d0 + /// track-proportional geometry stays unchanged — this only changes HOW + /// the sprite fills that rect). Neither (UV- + /// repeat — draws the small marker graphic several times to fill a + /// large proportional thumb rect, R4-2's own "tiled diamonds" report) + /// nor a naive 1:1 stretch across the full computed rect (would distort + /// a small marker into an elongated bar) is correct for this shape — + /// selects which + /// axis is being filled/centered: a vertical scrollbar's thumb rect + /// varies in height (X/Width stay the bar's own full width, matching + /// every other draw call in this class), a horizontal one varies in + /// width (Y/Height stay the bar's own full height). + /// + private void DrawThumbMarker( + UiRenderContext ctx, Func resolve, + uint id, float rectX, float rectY, float rectW, float rectH, bool vertical) + { + if (id == 0 || rectW <= 0f || rectH <= 0f) return; + var (tex, nativeW, nativeH) = resolve(id); + if (tex == 0 || nativeW == 0 || nativeH == 0) return; + + if (vertical) + { + float drawH = MathF.Min(nativeH, rectH); + float y = rectY + (rectH - drawH) * 0.5f; + ctx.DrawSprite(tex, rectX, y, rectW, drawH, 0f, 0f, rectW / nativeW, drawH / nativeH, Vector4.One); + } + else + { + float drawW = MathF.Min(nativeW, rectW); + float x = rectX + (rectW - drawW) * 0.5f; + ctx.DrawSprite(tex, x, rectY, drawW, rectH, 0f, 0f, drawW / nativeW, rectH / nativeH, Vector4.One); + } + } + private void DrawHorizontalModel( UiRenderContext ctx, Func resolve, @@ -309,10 +364,35 @@ public sealed class UiScrollbar : UiElement } else { - DrawTiled(ctx, resolve, ThumbSprite, tx, 0f, tw, Height); + // R4-2: horizontal counterpart of the vertical fallback above. + DrawThumbMarker(ctx, resolve, ThumbSprite, tx, 0f, tw, Height, vertical: false); } } + /// + /// Fix round F11 (Campaign CC CC6b-MOUNT review): the mirror-image + /// counterpart of the horizontal scalar draw block above, for scalar-mode + /// bars authored VERTICAL (taller than wide) — retail's chargen shade + /// scrollbar (0x10000321) is one, measured against the installed + /// EoR dat (Width=33 Height=85). Retail's own + /// UIElement_Scrollbar is one class handling both a model-driven + /// list scroll and a scalar-value slider on EITHER axis; this class only + /// had the horizontal half of the scalar shape before this fix, so a + /// vertically-authored scalar bar (like the shade control) drew nothing + /// scalar-specific and fell through to the model-mode branch below, + /// which requires a a + /// scalar-mode bar never has. + /// + private void DrawVerticalScalar( + UiRenderContext ctx, Func resolve) + { + DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height); + float thumbHeight = ScalarThumbExtent(resolve, Height); + float travel = MathF.Max(0f, Height - thumbHeight); + float y = travel * ScalarPosition; + DrawSprite(ctx, resolve, ThumbSprite, 0f, y, Width, thumbHeight); + } + /// Draw a sprite stretched 1:1 to the dest rect. private void DrawSprite(UiRenderContext ctx, Func resolve, uint id, float x, float y, float w, float h) @@ -412,8 +492,17 @@ public sealed class UiScrollbar : UiElement if (e.Type == UiEventType.MouseMove) _hoveredButton = ButtonAt(e.Data1, e.Data2); - if (Horizontal && ScalarChanged is not null) - return OnScalarEvent(e); + // Fix round F11: retail's chargen shade scrollbar (0x10000321) is + // authored VERTICAL (measured against the installed dat), but a + // scalar-mode bar (ScalarChanged set, no Model) has always been + // possible on either axis in retail's own UIElement_Scrollbar. + // Gating this dispatch on Horizontal silently dropped every mouse + // event for a vertical scalar bar — it fell through the Horizontal + // Model branch below too, then hit "Model is not {} m => return + // false" since a scalar bar has no Model, so NOTHING ever routed to + // ScalarChanged in production for this orientation. + if (ScalarChanged is not null) + return Horizontal ? OnScalarEvent(e) : OnVerticalScalarEvent(e); if (Horizontal && Model is not null) return OnHorizontalModelEvent(e); @@ -590,14 +679,77 @@ public sealed class UiScrollbar : UiElement return false; } - private float ScalarThumbWidth(Func? resolve) + /// F11: the vertical mirror of — + /// same click-thumb-to-drag / click-track-to-jump shape, along Y/Height + /// instead of X/Width. Reuses (otherwise only + /// touched by the vertical MODEL-mode drag, mutually exclusive with + /// scalar mode on one instance) rather than adding a third offset field. + /// + private bool OnVerticalScalarEvent(in UiEvent e) + { + switch (e.Type) + { + case UiEventType.MouseDown: + { + float thumbHeight = ScalarThumbExtent(SpriteResolve, Height); + float travel = MathF.Max(1f, Height - thumbHeight); + float thumbY = travel * ScalarPosition; + float y = e.Data2; + // OP5 re-check R2 (mirrored from OnScalarEvent): latch + // before the jump so the jump's own tick defers its flush + // to MouseUp's DragCompleted. + _draggingThumb = true; + if (y >= thumbY && y <= thumbY + thumbHeight) + { + _dragOffsetY = y - thumbY; + } + else + { + _dragOffsetY = thumbHeight * 0.5f; + ChangeScalarPosition((y - _dragOffsetY) / travel); + } + return true; + } + + case UiEventType.MouseMove when _draggingThumb: + { + float thumbHeight = ScalarThumbExtent(SpriteResolve, Height); + float travel = MathF.Max(1f, Height - thumbHeight); + ChangeScalarPosition(((float)e.Data2 - _dragOffsetY) / travel); + return true; + } + + case UiEventType.MouseUp: + { + bool wasDragging = _draggingThumb; + _draggingThumb = false; + _pressedButton = EndButton.None; + if (wasDragging) DragCompleted?.Invoke(); + return true; + } + } + + return false; + } + + private float ScalarThumbWidth(Func? resolve) => + ScalarThumbExtent(resolve, Width); + + /// F11: generalized over so + /// can size the thumb along the + /// authored axis (native sprite width for a horizontal bar, native + /// sprite height for a vertical one) instead of assuming horizontal. + /// + private float ScalarThumbExtent( + Func? resolve, float axisLength) { if (resolve is not null && ThumbSprite != 0) { - var (_, width, _) = resolve(ThumbSprite); - if (width > 0) return MathF.Min(width, Width); + var (_, width, height) = resolve(ThumbSprite); + int native = Horizontal ? width : height; + if (native > 0) return MathF.Min(native, axisLength); } - return MathF.Min(16f, Width); + return MathF.Min(16f, axisLength); } private void ChangeScalarPosition(float position) diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index 0f7ba89f..5bd2ab0e 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -146,6 +146,27 @@ public sealed class UiText : UiElement, IUiDatStateful /// public float Padding { get; set; } + /// + /// Campaign CC gate round 1 Batch E (R2-1): the four independent retail + /// text-inset margins (dat properties 0x23/0x24/0x25/ + /// 0x26's own doc + /// comment has the full decomp citation). Additive with + /// (every existing controller that sets + /// explicitly keeps behaving identically, since + /// these four default to 0 unless + /// seeds them from the DAT). Applied ONLY to the scrollable multi-line + /// path ( == false) — the chargen description boxes + /// that regressed in Batch C are all multi-line, and every authored + /// nonzero-margin box measured against the installed DAT so far is also + /// multi-line. The static Centered/RightAligned/OneLine single-line + /// paths are unchanged (still bare ) to keep this + /// fix's blast radius to the mechanism that actually regressed. + /// + public float MarginLeft { get; set; } + public float MarginRight { get; set; } + public float MarginTop { get; set; } + public float MarginBottom { get; set; } + /// Retail property 0x20. Independent of horizontal/vertical /// justification; false permits the normal multi-line layout path. public bool OneLine { get; set; } @@ -555,7 +576,10 @@ public sealed class UiText : UiElement, IUiDatStateful if (lines.Count == 0) return; float lh = _lastLineHeight; - float top = Padding, bottom = Height - Padding; + // R2-1: the multi-line viewport insets by BOTH Padding (the pre- + // existing uniform inset controllers already set) AND the four + // retail-authored margins (additive — see MarginTop's own doc). + float top = Padding + MarginTop, bottom = Height - Padding - MarginBottom; float innerH = bottom - top; float contentH = lines.Count * lh; @@ -731,11 +755,37 @@ public sealed class UiText : UiElement, IUiDatStateful float width = datFont is not null ? datFont.MeasureWidth(text) : bitmapFont?.MeasureWidth(text) ?? 0f; - if (Centered) - return Math.Max(Padding, (Width - width) * 0.5f); - if (RightAligned) - return Math.Max(Padding, Width - Padding - width); - return Padding; + return ContentOffsetX(Width, Padding, MarginLeft, MarginRight, width, Centered, RightAligned); + } + + /// + /// R2-1 (Campaign CC gate round 1 Batch E): pure per-line horizontal + /// placement for the MULTI-LINE (scrollable) path — the static + /// single-line Centered/RightAligned/OneLine branches in + /// have their own inline math and are + /// deliberately left on bare (see + /// 's own doc comment). Here, both + /// and the four retail margins inset the content + /// box a line lays out within. Pure/static so it is unit-testable + /// without a font or draw context — the same shape as + /// / above. + /// + public static float ContentOffsetX( + float elementWidth, + float padding, + float marginLeft, + float marginRight, + float lineWidth, + bool centered, + bool rightAligned) + { + float contentLeft = padding + marginLeft; + float contentRight = elementWidth - padding - marginRight; + if (centered) + return Math.Max(contentLeft, contentLeft + (contentRight - contentLeft - lineWidth) * 0.5f); + if (rightAligned) + return Math.Max(contentLeft, contentRight - lineWidth); + return contentLeft; } public override bool OnEvent(in UiEvent e) diff --git a/src/AcDream.Bake/AcDream.Bake.csproj b/src/AcDream.Bake/AcDream.Bake.csproj index 9b04f741..3557add4 100644 --- a/src/AcDream.Bake/AcDream.Bake.csproj +++ b/src/AcDream.Bake/AcDream.Bake.csproj @@ -12,6 +12,7 @@ + @@ -24,6 +25,7 @@ + diff --git a/src/AcDream.Bake/BakeCommandLine.cs b/src/AcDream.Bake/BakeCommandLine.cs new file mode 100644 index 00000000..ffdfb615 --- /dev/null +++ b/src/AcDream.Bake/BakeCommandLine.cs @@ -0,0 +1,133 @@ +using System.Globalization; + +namespace AcDream.Bake; + +internal sealed record BakeCommandLineOptions( + string DatDirectory, + string OutputPath, + HashSet? IdFilter, + HashSet? LandblockFilter, + int Threads, + bool ProgressJson); + +internal static class BakeCommandLine +{ + internal const string Usage = + "usage: acdream-bake --dat-dir [--out ] " + + "[--ids 0xId,0xId,...] [--landblocks 0xId,...] " + + "[--threads ] [--progress-json]\n" + + " acdream-bake --help"; + + public static bool IsHelpRequest(IReadOnlyList args) + { + ArgumentNullException.ThrowIfNull(args); + return args.Count == 1 + && args[0] is "--help" or "-h"; + } + + public static bool TryParse( + IReadOnlyList args, + TextWriter error, + out BakeCommandLineOptions? options) + { + ArgumentNullException.ThrowIfNull(args); + ArgumentNullException.ThrowIfNull(error); + + string? datDirectory = null; + string? outputPath = null; + HashSet? idFilter = null; + HashSet? landblockFilter = null; + int threads = Environment.ProcessorCount; + bool progressJson = false; + + for (int i = 0; i < args.Count; i++) + { + switch (args[i]) + { + case "--dat-dir": + datDirectory = Value(args, ref i); + break; + case "--out": + outputPath = Value(args, ref i); + break; + case "--ids": + idFilter = ParseHexList(Value(args, ref i), error); + break; + case "--landblocks": + landblockFilter = ParseHexList(Value(args, ref i), error); + break; + case "--threads": + if (int.TryParse( + Value(args, ref i), + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out int parsedThreads) + && parsedThreads > 0) + { + threads = parsedThreads; + } + break; + case "--progress-json": + progressJson = true; + break; + default: + error.WriteLine($"unrecognized argument: {args[i]}"); + options = null; + return false; + } + } + + if (string.IsNullOrWhiteSpace(datDirectory)) + { + error.WriteLine(Usage); + options = null; + return false; + } + + outputPath ??= Path.Combine(datDirectory, "acdream.pak"); + options = new BakeCommandLineOptions( + datDirectory, + outputPath, + idFilter, + landblockFilter, + threads, + progressJson); + return true; + } + + private static string? Value(IReadOnlyList args, ref int index) => + index + 1 < args.Count ? args[++index] : null; + + private static HashSet ParseHexList(string? raw, TextWriter error) + { + var result = new HashSet(); + if (string.IsNullOrWhiteSpace(raw)) + { + return result; + } + + foreach (string token in raw.Split( + ',', + StringSplitOptions.RemoveEmptyEntries + | StringSplitOptions.TrimEntries)) + { + string hex = token.StartsWith("0x", StringComparison.OrdinalIgnoreCase) + ? token[2..] + : token; + if (uint.TryParse( + hex, + NumberStyles.HexNumber, + CultureInfo.InvariantCulture, + out uint value)) + { + result.Add(value); + } + else + { + error.WriteLine($"warning: could not parse id '{token}' - skipped"); + } + } + + return result; + } +} diff --git a/src/AcDream.Bake/BakeOutputTransaction.cs b/src/AcDream.Bake/BakeOutputTransaction.cs index 0bc58e16..34f5cf16 100644 --- a/src/AcDream.Bake/BakeOutputTransaction.cs +++ b/src/AcDream.Bake/BakeOutputTransaction.cs @@ -9,11 +9,28 @@ namespace AcDream.Bake; /// public static class BakeOutputTransaction { + internal const string StagingMarker = ".acdream-bake."; + public static TResult WriteValidateAndPublish( string destinationPath, Func writeTemporary, Action validateTemporary, CancellationToken cancellationToken = default) + => WriteValidateAndPublish( + destinationPath, + writeTemporary, + validateTemporary, + beforePublicationLock: null, + beforePromotion: null, + cancellationToken); + + internal static TResult WriteValidateAndPublish( + string destinationPath, + Func writeTemporary, + Action validateTemporary, + Action? beforePublicationLock, + Action? beforePromotion, + CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath); ArgumentNullException.ThrowIfNull(writeTemporary); @@ -25,9 +42,7 @@ public static class BakeOutputTransaction throw new InvalidOperationException("destination has no parent directory"); Directory.CreateDirectory(directory); - string temporaryPath = Path.Combine( - directory, - $".{Path.GetFileName(fullDestination)}.{Guid.NewGuid():N}.tmp"); + string temporaryPath = CreateStagingPath(fullDestination, Guid.NewGuid()); try { @@ -36,6 +51,14 @@ public static class BakeOutputTransaction cancellationToken.ThrowIfCancellationRequested(); validateTemporary(temporaryPath, result); cancellationToken.ThrowIfCancellationRequested(); + beforePublicationLock?.Invoke(); + using IDisposable? publication = + BakePublicationGuard.AcquireIfRequested( + fullDestination, + cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + beforePromotion?.Invoke(); + cancellationToken.ThrowIfCancellationRequested(); // Same-volume MoveFileEx/rename is the publication primitive. // File.Replace additionally performs destination metadata/backup @@ -60,4 +83,21 @@ public static class BakeOutputTransaction } } } + + /// + /// Exact adjacent staging-name contract shared, by documentation and + /// conformance tests, with Launcher.Core. Keeping this tiny contract in + /// each BCL-facing assembly avoids an otherwise inverted project edge. + /// + internal static string CreateStagingPath(string destinationPath, Guid transactionId) + { + string fullDestination = Path.GetFullPath(destinationPath); + string directory = Path.GetDirectoryName(fullDestination) + ?? throw new InvalidOperationException( + "destination has no parent directory"); + return Path.Combine( + directory, + $".{Path.GetFileName(fullDestination)}{StagingMarker}" + + $"{transactionId:N}.tmp"); + } } diff --git a/src/AcDream.Bake/BakeProgressJsonWriter.cs b/src/AcDream.Bake/BakeProgressJsonWriter.cs new file mode 100644 index 00000000..8dd7ccfa --- /dev/null +++ b/src/AcDream.Bake/BakeProgressJsonWriter.cs @@ -0,0 +1,101 @@ +using System.Text.Json; + +namespace AcDream.Bake; + +public interface IBakeProgressSink +{ + void Started(uint bakeToolVersion, string outputPath); + + void Progress( + string phase, + long completed, + long total, + int failures, + double elapsedSeconds, + double etaSeconds, + long privateBytes, + long managedBytes); + + void Completed(uint bakeToolVersion, long outputBytes, int failures); + + void Error(string message); +} + +/// +/// Version-1 JSON-lines machine channel enabled only by +/// --progress-json. Ordinary human console lines remain unchanged and +/// share stdout; consumers identify these records by shape instead of +/// scraping human prose. +/// +public sealed class BakeProgressJsonWriter(TextWriter output) : IBakeProgressSink +{ + public const int CurrentVersion = 1; + + private readonly TextWriter _output = output + ?? throw new ArgumentNullException(nameof(output)); + private readonly object _gate = new(); + + public void Started(uint bakeToolVersion, string outputPath) => + Write(new + { + v = CurrentVersion, + e = "started", + t = DateTimeOffset.UtcNow, + bakeToolVersion, + outputPath, + }); + + public void Progress( + string phase, + long completed, + long total, + int failures, + double elapsedSeconds, + double etaSeconds, + long privateBytes, + long managedBytes) => + Write(new + { + v = CurrentVersion, + e = "progress", + t = DateTimeOffset.UtcNow, + phase, + completed, + total, + failures, + elapsedSeconds, + etaSeconds, + privateBytes, + managedBytes, + }); + + public void Completed(uint bakeToolVersion, long outputBytes, int failures) => + Write(new + { + v = CurrentVersion, + e = "completed", + t = DateTimeOffset.UtcNow, + bakeToolVersion, + outputBytes, + failures, + }); + + public void Error(string message) => + Write(new + { + v = CurrentVersion, + e = "error", + t = DateTimeOffset.UtcNow, + message, + }); + + private void Write(T value) + { + string line = JsonSerializer.Serialize(value); + lock (_gate) + { + _output.WriteLine(line); + _output.Flush(); + } + } +} diff --git a/src/AcDream.Bake/BakeProgressReporter.cs b/src/AcDream.Bake/BakeProgressReporter.cs new file mode 100644 index 00000000..954fddac --- /dev/null +++ b/src/AcDream.Bake/BakeProgressReporter.cs @@ -0,0 +1,34 @@ +namespace AcDream.Bake; + +internal static class BakeProgressReporter +{ + public static void Write( + TextWriter humanOutput, + IBakeProgressSink? machineOutput, + string phase, + long completed, + int total, + int failures, + TimeSpan elapsed, + double etaSeconds, + long privateBytes, + long managedBytes) + { + ArgumentNullException.ThrowIfNull(humanOutput); + humanOutput.WriteLine( + $"[{elapsed:hh\\:mm\\:ss}] extracted {completed:N0}/{total:N0}, " + + $"failures={failures:N0}, elapsed={elapsed.TotalSeconds:F0}s, " + + $"ETA={etaSeconds:F0}s, " + + $"private={privateBytes / 1024.0 / 1024.0:F0}MB, " + + $"managed={managedBytes / 1024.0 / 1024.0:F0}MB"); + machineOutput?.Progress( + phase, + completed, + total, + failures, + elapsed.TotalSeconds, + etaSeconds, + privateBytes, + managedBytes); + } +} diff --git a/src/AcDream.Bake/BakePublicationGuard.cs b/src/AcDream.Bake/BakePublicationGuard.cs new file mode 100644 index 00000000..0b35a811 --- /dev/null +++ b/src/AcDream.Bake/BakePublicationGuard.cs @@ -0,0 +1,80 @@ +using AcDream.Platform; + +namespace AcDream.Bake; + +/// +/// Optional launcher authorization checked immediately before atomic +/// publication. Standalone Bake runs have no nonce environment variable and +/// retain the original unguarded behavior. +/// +internal static class BakePublicationGuard +{ + private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(50); + + internal static IDisposable? AcquireIfRequested( + string outputPath, + CancellationToken cancellationToken) + { + string? nonce = Environment.GetEnvironmentVariable( + BakePublicationGuardPaths.NonceEnvironmentVariable); + if (nonce is null) + { + return null; + } + + if (!BakePublicationGuardPaths.IsValidNonce(nonce)) + { + throw new InvalidOperationException( + "The launcher bake publication nonce is invalid."); + } + + string lockPath = BakePublicationGuardPaths.GetPublishLockPath( + outputPath); + Directory.CreateDirectory( + Path.GetDirectoryName(lockPath) + ?? throw new InvalidOperationException( + "The bake publication lock has no parent directory.")); + + FileStream? lease = null; + while (lease is null) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + lease = new FileStream( + lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + options: FileOptions.None); + } + catch (IOException) + { + cancellationToken.WaitHandle.WaitOne(RetryDelay); + } + } + + try + { + string authorizationPath = + BakePublicationGuardPaths.GetAuthorizationPath(outputPath); + string authorized = File.Exists(authorizationPath) + ? File.ReadAllText(authorizationPath) + : string.Empty; + if (!string.Equals(authorized, nonce, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "This bake process is no longer authorized to publish its output."); + } + + return lease; + } + catch + { + lease.Dispose(); + throw; + } + } + +} diff --git a/src/AcDream.Bake/BakeRunner.cs b/src/AcDream.Bake/BakeRunner.cs index 9ea6f1bb..6b60a2ea 100644 --- a/src/AcDream.Bake/BakeRunner.cs +++ b/src/AcDream.Bake/BakeRunner.cs @@ -22,6 +22,7 @@ public sealed record BakeOptions public HashSet? LandblockFilter { get; init; } public int Threads { get; init; } = System.Environment.ProcessorCount; public CancellationToken CancellationToken { get; init; } + public IBakeProgressSink? Progress { get; init; } } /// Compact result used by the full-scale gate and deterministic tests. @@ -89,6 +90,7 @@ public static class BakeRunner throw new ArgumentOutOfRangeException(nameof(options), "thread count must be positive"); options.CancellationToken.ThrowIfCancellationRequested(); + options.Progress?.Started(PakFormat.CurrentBakeToolVersion, options.OutPath); var totalStopwatch = Stopwatch.StartNew(); var report = BakeOutputTransaction.WriteValidateAndPublish( options.OutPath, @@ -112,6 +114,10 @@ public static class BakeRunner }; PrintSummary(report, options.OutPath); + options.Progress?.Completed( + report.Header.BakeToolVersion, + report.OutputBytes, + report.Failures); return report; } @@ -273,6 +279,8 @@ public static class BakeRunner failures.Count, stopwatch.Elapsed, lastProgressReport, + options.Progress, + "mesh", batchStart + BatchSize >= ordinaryWork.Count && envCatalog.UniqueGeometryCount == 0); } @@ -372,6 +380,8 @@ public static class BakeRunner failures.Count, stopwatch.Elapsed, lastProgressReport, + options.Progress, + "mesh", batchStart + BatchSize >= envCatalog.Groups.Count); } @@ -571,6 +581,8 @@ public static class BakeRunner failures.Count, collisionStopwatch.Elapsed, lastProgressReport, + options.Progress, + "collision", final: false); } @@ -757,6 +769,8 @@ public static class BakeRunner failures.Count, collisionStopwatch.Elapsed, lastProgressReport, + options.Progress, + "collision", final: false); } } @@ -769,6 +783,8 @@ public static class BakeRunner failures.Count, collisionStopwatch.Elapsed, lastProgressReport, + options.Progress, + "collision", final: true); writer.Finish(); @@ -910,6 +926,8 @@ public static class BakeRunner int failures, TimeSpan elapsed, Stopwatch lastProgressReport, + IBakeProgressSink? progress, + string phase, bool final) { if (!final && lastProgressReport.Elapsed.TotalSeconds < 5) @@ -920,11 +938,18 @@ public static class BakeRunner using var process = Process.GetCurrentProcess(); process.Refresh(); long managedHeap = GC.GetGCMemoryInfo().HeapSizeBytes; - Console.WriteLine( - $"[{elapsed:hh\\:mm\\:ss}] extracted {done:N0}/{total:N0}, " + - $"failures={failures:N0}, elapsed={elapsed.TotalSeconds:F0}s, " + - $"ETA={etaSeconds:F0}s, private={process.PrivateMemorySize64 / 1024.0 / 1024.0:F0}MB, " + - $"managed={managedHeap / 1024.0 / 1024.0:F0}MB"); + long privateBytes = process.PrivateMemorySize64; + BakeProgressReporter.Write( + Console.Out, + progress, + phase, + done, + total, + failures, + elapsed, + etaSeconds, + privateBytes, + managedHeap); lastProgressReport.Restart(); } diff --git a/src/AcDream.Bake/Program.cs b/src/AcDream.Bake/Program.cs index 2d7ee8c0..5aba455e 100644 --- a/src/AcDream.Bake/Program.cs +++ b/src/AcDream.Bake/Program.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; using AcDream.Bake; // acdream-bake: offline CLI producing a versioned pak file containing every @@ -13,66 +9,41 @@ using AcDream.Bake; // // Plan: docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md, Task 5. -string? datDir = null; -string? outPath = null; -HashSet? idFilter = null; -HashSet? landblockFilter = null; -int threads = Environment.ProcessorCount; - -for (int i = 0; i < args.Length; i++) { - switch (args[i]) { - case "--dat-dir": - datDir = args.ElementAtOrDefault(++i); - break; - case "--out": - outPath = args.ElementAtOrDefault(++i); - break; - case "--ids": - idFilter = ParseHexList(args.ElementAtOrDefault(++i)); - break; - case "--landblocks": - landblockFilter = ParseHexList(args.ElementAtOrDefault(++i)); - break; - case "--threads": - if (int.TryParse(args.ElementAtOrDefault(++i), out var t) && t > 0) threads = t; - break; - default: - Console.Error.WriteLine($"unrecognized argument: {args[i]}"); - return 2; - } +if (BakeCommandLine.IsHelpRequest(args)) +{ + Console.Out.WriteLine(BakeCommandLine.Usage); + return 0; } -if (string.IsNullOrWhiteSpace(datDir)) { - Console.Error.WriteLine("usage: acdream-bake --dat-dir [--out ] [--ids 0xId,0xId,...] [--landblocks 0xId,...] [--threads ]"); +if (!BakeCommandLine.TryParse(args, Console.Error, out BakeCommandLineOptions? command)) +{ return 2; } -if (!Directory.Exists(datDir)) { - Console.Error.WriteLine($"error: directory not found: {datDir}"); +if (!Directory.Exists(command!.DatDirectory)) +{ + Console.Error.WriteLine($"error: directory not found: {command.DatDirectory}"); return 2; } -outPath ??= Path.Combine(datDir, "acdream.pak"); - -return BakeRunner.Run(new BakeOptions { - DatDir = datDir, - OutPath = outPath, - IdFilter = idFilter, - LandblockFilter = landblockFilter, - Threads = threads, -}); - -static HashSet ParseHexList(string? raw) { - var result = new HashSet(); - if (string.IsNullOrWhiteSpace(raw)) return result; - foreach (var token in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { - var hex = token.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? token[2..] : token; - if (uint.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out var value)) { - result.Add(value); - } - else { - Console.Error.WriteLine($"warning: could not parse id '{token}' — skipped"); - } - } - return result; +IBakeProgressSink? progress = command.ProgressJson + ? new BakeProgressJsonWriter(Console.Out) + : null; +try +{ + return BakeRunner.Run(new BakeOptions + { + DatDir = command.DatDirectory, + OutPath = command.OutputPath, + IdFilter = command.IdFilter, + LandblockFilter = command.LandblockFilter, + Threads = command.Threads, + Progress = progress, + }); +} +catch (Exception exception) +{ + progress?.Error(exception.Message); + Console.Error.WriteLine($"error: {exception.Message}"); + return 1; } diff --git a/src/AcDream.Content/CharGen/ChargenAppearanceCatalog.cs b/src/AcDream.Content/CharGen/ChargenAppearanceCatalog.cs new file mode 100644 index 00000000..5d521672 --- /dev/null +++ b/src/AcDream.Content/CharGen/ChargenAppearanceCatalog.cs @@ -0,0 +1,155 @@ +using System.Collections.Concurrent; +using System.Collections.Frozen; +using AcDream.Core.CharGen; +using DatClothingTable = DatReaderWriter.DBObjs.ClothingTable; +using DatPalette = DatReaderWriter.DBObjs.Palette; +using DatPalSet = DatReaderWriter.DBObjs.PalSet; +using DatCloObjectEffect = DatReaderWriter.Types.CloObjectEffect; +using DatCloSubPalette = DatReaderWriter.Types.CloSubPalette; + +namespace AcDream.Content.CharGen; + +/// +/// DAT-backed / +/// implementation: reads PalSet (0x0F......) and ClothingTable (0x19......) +/// dat objects on demand and projects them into 's +/// pure Core types, matching ChargenTableReader's "no Chorizite leak" +/// discipline for everything it returns. Both lookups cache by dat id — a +/// live preview re-composes on every appearance change, and the same +/// PalSet/ClothingTable ids repeat constantly across heritages, genders, and +/// re-selections within one session. +/// +/// +/// NOT thread-safe on its own (fix round F7, CC6b-MOUNT review): +/// / do a lazy raw +/// _dats.Get<T>() read on first use per id — and the shared +/// DatCollection every sibling in this codebase guards with the +/// process-wide DAT lock is itself NOT thread-safe +/// (feedback_phase_a1_hotfix_saga.md). Every call site MUST hold that +/// same lock (ChargenPreviewController's _datLock, the +/// composition root's d.DatLock) around calls into this class, exactly +/// like every other DAT-touching call in this codebase already does. This +/// class's own caches only +/// protect the CACHE from concurrent mutation — they do nothing for the +/// underlying DatCollection read the cache miss triggers. +/// +/// +/// +/// (Campaign CC gate round 1 +/// Batch G, R2-5): the real color-wheel/swatch mechanism +/// (ChargenSwatchColorResolver) needs one more DAT read this class +/// didn't previously do — a raw Palette dat object's (0x04......) own color +/// table, retail's Palette::get_color32 equivalent. Same lazy-cache +/// shape as /, +/// same DAT-lock obligation on every call site. +/// +/// +public sealed class ChargenAppearanceCatalog : + IChargenPalSetSource, IChargenClothingTableSource, IChargenPaletteColorSource +{ + private readonly IDatReaderWriter _dats; + private readonly ConcurrentDictionary _palSets = new(); + private readonly ConcurrentDictionary _clothingTables = new(); + private readonly ConcurrentDictionary _palettes = new(); + + public ChargenAppearanceCatalog(IDatReaderWriter dats) + { + _dats = dats ?? throw new ArgumentNullException(nameof(dats)); + } + + public ChargenPalSet? TryGetPalSet(uint palSetId) => + _palSets.GetOrAdd(palSetId, LoadPalSet); + + public ChargenClothingTable? TryGetClothingTable(uint clothingTableId) => + _clothingTables.GetOrAdd(clothingTableId, LoadClothingTable); + + /// + /// Retail's ClientCharGenState::GetColorFromPal @0x00563990: load + /// the Palette dat object and read its color table at a fixed index — + /// direct ARGB[index], no averaging, no shade indirection. Unlike + /// retail's own unchecked array read, this bounds-checks + /// against the loaded palette's actual color + /// count and returns false rather than reading out of range (see + /// 's own doc for + /// why that divergence is deliberate). + /// + public bool TryGetColor(uint paletteId, int index, out ChargenSwatchRgb color) + { + color = default; + DatPalette? palette = _palettes.GetOrAdd(paletteId, id => _dats.Get(id)); + if (palette is null || index < 0 || index >= palette.Colors.Count) + return false; + + DatReaderWriter.Types.ColorARGB c = palette.Colors[index]; + color = new ChargenSwatchRgb(c.Red, c.Green, c.Blue); + return true; + } + + private ChargenPalSet? LoadPalSet(uint id) + { + DatPalSet? palSet = _dats.Get(id); + if (palSet is null) + return null; + + var ids = new uint[palSet.Palettes.Count]; + for (int i = 0; i < palSet.Palettes.Count; i++) + ids[i] = palSet.Palettes[i].DataId; + return new ChargenPalSet(Array.AsReadOnly(ids)); + } + + private ChargenClothingTable? LoadClothingTable(uint id) + { + DatClothingTable? table = _dats.Get(id); + if (table is null) + return null; + + var baseEffects = new Dictionary( + table.ClothingBaseEffects.Count); + foreach (var pair in table.ClothingBaseEffects) + baseEffects[pair.Key.DataId] = ProjectBaseEffect(pair.Value.CloObjectEffects); + + var templates = new Dictionary( + table.ClothingSubPalEffects.Count); + foreach (var pair in table.ClothingSubPalEffects) + templates[pair.Key] = ProjectPaletteTemplate(pair.Value.CloSubPalettes); + + return new ChargenClothingTable( + baseEffects.ToFrozenDictionary(), + templates.ToFrozenDictionary()); + } + + private static ChargenClothingBaseEffect ProjectBaseEffect( + IReadOnlyList objectEffects) + { + var partChanges = new List(objectEffects.Count); + var textureChanges = new List(); + foreach (DatCloObjectEffect effect in objectEffects) + { + var partIndex = (byte)effect.Index; + partChanges.Add(new ChargenAnimPartChange(partIndex, effect.ModelId.DataId)); + foreach (var tex in effect.CloTextureEffects) + { + textureChanges.Add(new ChargenTextureChange( + partIndex, tex.OldTexture.DataId, tex.NewTexture.DataId)); + } + } + return new ChargenClothingBaseEffect( + Array.AsReadOnly(partChanges.ToArray()), + Array.AsReadOnly(textureChanges.ToArray())); + } + + private static ChargenClothingPaletteTemplate ProjectPaletteTemplate( + IReadOnlyList subPalettes) + { + var choices = new ChargenClothingSubPaletteChoice[subPalettes.Count]; + for (int i = 0; i < subPalettes.Count; i++) + { + DatCloSubPalette sub = subPalettes[i]; + var ranges = new ChargenClothingSubPaletteRange[sub.Ranges.Count]; + for (int j = 0; j < sub.Ranges.Count; j++) + ranges[j] = new ChargenClothingSubPaletteRange(sub.Ranges[j].Offset, sub.Ranges[j].NumColors); + choices[i] = new ChargenClothingSubPaletteChoice(sub.PaletteSet.DataId, Array.AsReadOnly(ranges)); + } + return new ChargenClothingPaletteTemplate(Array.AsReadOnly(choices)); + } +} diff --git a/src/AcDream.Content/CharGen/ChargenTableReader.cs b/src/AcDream.Content/CharGen/ChargenTableReader.cs new file mode 100644 index 00000000..f902ab79 --- /dev/null +++ b/src/AcDream.Content/CharGen/ChargenTableReader.cs @@ -0,0 +1,297 @@ +using System.Collections.Frozen; +using AcDream.Core.CharGen; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; +using CoreChargenObjDesc = AcDream.Core.CharGen.ChargenObjDesc; +using DatCharGen = DatReaderWriter.DBObjs.CharGen; +using DatObjDesc = DatReaderWriter.Types.ObjDesc; +using DatSkillTable = DatReaderWriter.DBObjs.SkillTable; + +namespace AcDream.Content.CharGen; + +/// +/// Projects portal.dat's CharGen table (id , +/// retail ACCharGenData::Serialize @ 0x005C36D0) PLUS the global +/// SkillTable (id ) into acdream's +/// presentation-free tree. +/// Mirrors MagicCatalog.Load's shape: one static entry point over +/// , every returned collection is frozen at +/// projection (ToFrozenDictionary / Array.AsReadOnly, matching +/// MagicCatalog's pattern), and no Chorizite types cross into the +/// returned model. Cross-checked against ACE's +/// ACE.DatLoader.FileTypes.CharGen + +/// ACE.DatLoader.Entity.HeritageGroupCG/SexCG/TemplateCG loaders, +/// which unpack the identical field order from the same DAT bytes, and +/// against ACE.DatLoader.FileTypes.SkillTable for the global +/// skill-cost fallback (see +/// ). +/// +public static class ChargenTableReader +{ + /// Retail's CharGen DAT file id (ACE: + /// ACE.DatLoader.FileTypes.CharGen.FILE_ID). + public const uint ChargenTableDid = 0x0E000002u; + + /// Retail's global SkillTable DAT file id (ACE: + /// ACE.DatLoader.FileTypes.SkillTable.FILE_ID) — the fallback + /// retail's ACCharGenData::GetSkillTrainedCost @ 0x005C26D0 / + /// GetSkillSpecializedCost @ 0x005C27D0 read via + /// DBCache::GetFromEnumStatic(4, 2, 0x10000004) when a heritage's + /// own skill-cost list has no entry for a skill id. + public const uint SkillTableDid = 0x0E000004u; + + /// + /// Loads and projects the installed CharGen table plus the global + /// SkillTable fallback. Returns if + /// the CharGen table is missing from the supplied dat source (mirrors + /// MagicCatalog's tolerance for a missing optional table — + /// callers that require the table present should check + /// HeritagesById.Count themselves). A missing global SkillTable + /// degrades to an empty fallback dictionary rather than failing the + /// whole load — the per-heritage costs (the common case) still work. + /// + public static ChargenOptions Load(IDatReaderWriter dats) + { + ArgumentNullException.ThrowIfNull(dats); + + DatCharGen? table = dats.Get(ChargenTableDid); + if (table is null) + return ChargenOptions.Empty; + + DatSkillTable? skillTable = dats.Get(SkillTableDid); + return Project(table, skillTable); + } + + /// Pure projection from an already-loaded DAT record — split out + /// from so tests can exercise it against + /// hand-built fixtures without a live DAT. + /// is optional (mirrors 's + /// missing-table tolerance) and projects into + /// . + public static ChargenOptions Project(DatCharGen table, DatSkillTable? skillTable = null) + { + ArgumentNullException.ThrowIfNull(table); + + var starterAreas = new ChargenStarterArea[table.StartingAreas.Count]; + for (int i = 0; i < table.StartingAreas.Count; i++) + starterAreas[i] = ProjectStarterArea(i, table.StartingAreas[i]); + + var heritagesById = new Dictionary(table.HeritageGroups.Count); + foreach (KeyValuePair pair in table.HeritageGroups) + heritagesById[pair.Key] = ProjectHeritage(pair.Key, pair.Value); + + var globalSkillCosts = new Dictionary(skillTable?.Skills.Count ?? 0); + // Group 2 (Campaign CC gate round 1 closeout): SkillBase.MinLevel/ + // Description/Formula — GLOBAL only, no per-heritage counterpart + // (see ChargenSkillDetail's own doc). + var globalSkillDetails = new Dictionary(skillTable?.Skills.Count ?? 0); + if (skillTable is not null) + { + foreach (KeyValuePair pair in skillTable.Skills) + { + uint skillId = (uint)pair.Key; + SkillBase skill = pair.Value; + globalSkillCosts[skillId] = new ChargenSkillCost( + skillId, + skill.TrainedCost, + skill.SpecializedCost); + globalSkillDetails[skillId] = new ChargenSkillDetail( + skillId, + skill.MinLevel, + skill.Description.Value, + new ChargenSkillFormula( + skill.Formula.AdditiveBonus, + skill.Formula.Attribute1Multiplier, + skill.Formula.Attribute2Multiplier, + skill.Formula.Divisor, + (uint)skill.Formula.Attribute1, + (uint)skill.Formula.Attribute2)); + } + } + + return new ChargenOptions( + Array.AsReadOnly(starterAreas), + heritagesById.ToFrozenDictionary(), + globalSkillCosts.ToFrozenDictionary(), + globalSkillDetails.ToFrozenDictionary()); + } + + private static ChargenStarterArea ProjectStarterArea(int index, StartingArea area) + { + var locations = new ChargenPosition[area.Locations.Count]; + for (int i = 0; i < area.Locations.Count; i++) + { + Position position = area.Locations[i]; + locations[i] = new ChargenPosition( + position.CellId, + position.Frame.Origin, + position.Frame.Orientation); + } + return new ChargenStarterArea(index, area.Name.Value, Array.AsReadOnly(locations)); + } + + private static ChargenHeritageOptions ProjectHeritage(uint heritageId, HeritageGroupCG cg) + { + var skillCosts = new Dictionary(cg.Skills.Count); + foreach (SkillCG skill in cg.Skills) + { + uint skillId = (uint)skill.Id; + skillCosts[skillId] = new ChargenSkillCost(skillId, skill.NormalCost, skill.PrimaryCost); + } + + var templates = new ChargenTemplate[cg.Templates.Count]; + for (int i = 0; i < cg.Templates.Count; i++) + templates[i] = ProjectTemplate(cg.Templates[i]); + + var gendersByKey = new Dictionary(cg.Genders.Count); + foreach (KeyValuePair pair in cg.Genders) + gendersByKey[pair.Key] = ProjectGender(pair.Key, pair.Value); + + return new ChargenHeritageOptions( + heritageId, + cg.Name.Value, + cg.IconId.DataId, + cg.SetupId.DataId, + cg.EnvironmentSetupId.DataId, + cg.AttributeCredits, + cg.SkillCredits, + Array.AsReadOnly(cg.PrimaryStartAreas.ToArray()), + Array.AsReadOnly(cg.SecondaryStartAreas.ToArray()), + skillCosts.ToFrozenDictionary(), + Array.AsReadOnly(templates), + gendersByKey.ToFrozenDictionary()); + } + + private static ChargenTemplate ProjectTemplate(TemplateCG template) + { + var normalSkills = new uint[template.NormalSkills.Count]; + for (int i = 0; i < template.NormalSkills.Count; i++) + normalSkills[i] = (uint)template.NormalSkills[i]; + + var primarySkills = new uint[template.PrimarySkills.Count]; + for (int i = 0; i < template.PrimarySkills.Count; i++) + primarySkills[i] = (uint)template.PrimarySkills[i]; + + return new ChargenTemplate( + template.Name.Value, + template.IconId.DataId, + template.Title, + new ChargenAttributeValues( + template.Strength, + template.Endurance, + template.Coordination, + template.Quickness, + template.Focus, + template.Self), + Array.AsReadOnly(normalSkills), + Array.AsReadOnly(primarySkills)); + } + + private static ChargenGenderOptions ProjectGender(int genderKey, SexCG sex) + { + var hairStyles = new ChargenHairStyle[sex.HairStyles.Count]; + for (int i = 0; i < sex.HairStyles.Count; i++) + { + HairStyleCG hair = sex.HairStyles[i]; + hairStyles[i] = new ChargenHairStyle( + hair.IconId.DataId, + hair.Bald, + hair.AlternateSetup, + ProjectObjDesc(hair.ObjDesc)); + } + + var eyeStrips = new ChargenEyeStrip[sex.EyeStrips.Count]; + for (int i = 0; i < sex.EyeStrips.Count; i++) + { + EyeStripCG eye = sex.EyeStrips[i]; + eyeStrips[i] = new ChargenEyeStrip( + eye.IconId.DataId, + eye.BaldIconId, + ProjectObjDesc(eye.ObjDesc), + ProjectObjDesc(eye.BaldObjDesc)); + } + + var noseStrips = new ChargenFaceStrip[sex.NoseStrips.Count]; + for (int i = 0; i < sex.NoseStrips.Count; i++) + { + FaceStripCG strip = sex.NoseStrips[i]; + noseStrips[i] = new ChargenFaceStrip(strip.IconId.DataId, ProjectObjDesc(strip.ObjDesc)); + } + + var mouthStrips = new ChargenFaceStrip[sex.MouthStrips.Count]; + for (int i = 0; i < sex.MouthStrips.Count; i++) + { + FaceStripCG strip = sex.MouthStrips[i]; + mouthStrips[i] = new ChargenFaceStrip(strip.IconId.DataId, ProjectObjDesc(strip.ObjDesc)); + } + + return new ChargenGenderOptions( + genderKey, + sex.Name.Value, + sex.Scale, + sex.SetupId.DataId, + sex.SoundTable.DataId, + sex.IconId.DataId, + sex.BasePalette.DataId, + sex.SkinPalSet.DataId, + sex.PhysicsTable.DataId, + sex.MotionTable.DataId, + sex.CombatTable.DataId, + ProjectObjDesc(sex.BaseObjDesc), + Array.AsReadOnly(sex.HairColors.ToArray()), + Array.AsReadOnly(hairStyles), + Array.AsReadOnly(sex.EyeColors.ToArray()), + Array.AsReadOnly(eyeStrips), + Array.AsReadOnly(noseStrips), + Array.AsReadOnly(mouthStrips), + ProjectGearList(sex.Headgears), + ProjectGearList(sex.Shirts), + ProjectGearList(sex.Pants), + ProjectGearList(sex.Footwear), + Array.AsReadOnly(sex.ClothingColors.ToArray())); + } + + private static IReadOnlyList ProjectGearList(List gearList) + { + var result = new ChargenGearOption[gearList.Count]; + for (int i = 0; i < gearList.Count; i++) + { + GearCG gear = gearList[i]; + result[i] = new ChargenGearOption(gear.Name.Value, gear.ClothingTable.DataId, gear.WeenieDefault); + } + return Array.AsReadOnly(result); + } + + private static CoreChargenObjDesc ProjectObjDesc(DatObjDesc objDesc) + { + var subPalettes = new ChargenSubPalette[objDesc.SubPalettes.Count]; + for (int i = 0; i < objDesc.SubPalettes.Count; i++) + { + SubPalette sub = objDesc.SubPalettes[i]; + subPalettes[i] = new ChargenSubPalette(sub.SubId.DataId, sub.Offset, sub.NumColors); + } + + var textureChanges = new ChargenTextureChange[objDesc.TextureChanges.Count]; + for (int i = 0; i < objDesc.TextureChanges.Count; i++) + { + TextureMapChange change = objDesc.TextureChanges[i]; + textureChanges[i] = new ChargenTextureChange( + change.PartIndex, + change.OldTexture.DataId, + change.NewTexture.DataId); + } + + var animPartChanges = new ChargenAnimPartChange[objDesc.AnimPartChanges.Count]; + for (int i = 0; i < objDesc.AnimPartChanges.Count; i++) + { + AnimationPartChange change = objDesc.AnimPartChanges[i]; + animPartChanges[i] = new ChargenAnimPartChange(change.PartIndex, change.PartId.DataId); + } + + return new CoreChargenObjDesc( + objDesc.PaletteId.DataId, + Array.AsReadOnly(subPalettes), + Array.AsReadOnly(textureChanges), + Array.AsReadOnly(animPartChanges)); + } +} diff --git a/src/AcDream.Core.Net/Messages/CharGenVerificationResponse.cs b/src/AcDream.Core.Net/Messages/CharGenVerificationResponse.cs new file mode 100644 index 00000000..1640b143 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/CharGenVerificationResponse.cs @@ -0,0 +1,161 @@ +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Shared parser for opcode 0xF643 — retail's +/// CharacterGenerationVerificationResponse shape, which BOTH +/// (opcode 0xF7D9 request) and +/// (opcode 0xF656 request) receive on +/// the exact same wire opcode — a genuine retail opcode reuse, confirmed by +/// ACE's own GameMessageOpcode.cs declaring both +/// CharacterCreateResponse = 0xF643 and +/// CharacterRestoreResponse = 0xF643, // This is a duplicate.... +/// +/// +/// Campaign CC CC2: this type is the promotion of the parse logic +/// that used to live only in (Campaign +/// LA slice LA7a). Character creation now exists (), +/// so the two message families that collide on this opcode are both real and +/// both need it — keeps its own +/// shape for source compatibility and +/// delegates to this type internally; new code (the create response, +/// WorldSession.CharacterCreateResponseReceived) consumes +/// directly. A caller cannot tell "restore response" +/// from "create response" by opcode or shape alone — WorldSession +/// disambiguates by tracking which outbound request (restore vs. create) it +/// is awaiting a reply to (see WorldSession's awaiting-request latch). +/// That latch is not merely a reasonable design — it is retail's OWN +/// mechanism: Handle_CharGenVerificationResponse@0x0055E8B0 case 1 +/// branches on the client's persistent chargen state, +/// GetVerificationState() == PENDING → new CharacterIdentity +/// + AddIdentity (a create it initiated), else → unpack into the +/// existing identity at slot (a restore). Same discriminator, one +/// layer down (CC2 review's fidelity note). +/// +/// +/// +/// Wire layout, verbatim from ACE's GameMessageCharacterCreateResponse.cs +/// / GameMessageCharacterRestore.cs (both write the identical shape) +/// and cross-checked against holtburger's +/// CharacterCreateResponseData::unpack +/// (holtburger-protocol/src/messages/character/types.rs:379-410): +/// +/// +/// +/// u32 opcode (0xF643) +/// u32 code (CharacterGenerationVerificationResponse) +/// -- only when code == Ok -- +/// u32 guid +/// String16L name +/// u32 secondsGreyedOut +/// +/// +/// +/// is a verbatim port of ACE's +/// CharacterGenerationVerificationResponse enum +/// (ACE.Server/Network/Enum/CharacterGenerationVerificationResponse.cs), +/// which is itself retail's own dialog dispatch table +/// (Handle_CharGenVerificationResponse@0x0055E8B0 + +/// gmCharGenMainUI::RecvNotice_CharGenVerificationResponse@0x004e9030's +/// own jump table). CC5 review-fix round F2 (2026-08-16) correction: +/// every non-Ok code shows a dialog — there is no silent branch. +/// NameInUseID_Character_Err_NameReserved, +/// NameBannedID_Character_Err_NameBanned, +/// AdminPrivilegeDeniedID_Character_Err_NameAdminDenied, +/// and Pending/Corrupt/DatabaseDown/Undef/any +/// unrecognized code ALL resolve to ID_Character_Err_NameDBDown — +/// Pending is an explicit switch case landing on that same label, +/// and Undef/out-of-range falls through +/// RecvNotice_CharGenVerificationResponse's own +/// (arg2-1) > 6 unsigned-underflow default arm to the identical +/// label. This corrects an earlier (wrong) reading of the decomp that +/// treated Pending/Undef as a silent state reset — notably ACE sends +/// Pending for a disabled-Olthoi rejection +/// (CharacterHandler.CharacterCreateEx, +/// olthoi_play_disabled branch), so that specific rejection now +/// correctly surfaces the NameDBDown dialog, matching retail, instead of +/// silently resetting verification state. Dialog presentation itself is +/// CC5's job (App layer), not this Core.Net type's. +/// +/// +public static class CharGenVerificationResponse +{ + public const uint ResponseOpcode = 0xF643u; + + /// + /// Verbatim port of ACE's CharacterGenerationVerificationResponse + /// enum, which is retail's own Handle_CharGenVerificationResponse + /// dispatch table. + /// + public enum Code : uint + { + Undef = 0, + Ok = 1, + Pending = 2, + NameInUse = 3, + NameBanned = 4, + Corrupt = 5, + DatabaseDown = 6, + AdminPrivilegeDenied = 7, + } + + /// + /// Parsed 0xF643 body. , , and + /// are only populated when + /// equals — retail omits + /// them entirely on the wire otherwise (both + /// GameMessageCharacterCreateResponse and + /// GameMessageCharacterRestore gate the trailing fields on + /// response == ... .Ok). + /// + public readonly record struct Parsed( + uint RawCode, + uint? Guid, + string? Name, + uint? SecondsGreyedOut) + { + /// + /// Best-effort named view of . A plain enum + /// cast never throws in C#, so this is safe even for a value retail + /// never defined — always trust as the source + /// of truth. + /// + public Code AsCode => (Code)RawCode; + + /// True when the trailing identity fields are present. + public bool IsOk => RawCode == (uint)Code.Ok; + } + + /// + /// Parse a 0xF643 body. must start with + /// the 4-byte opcode. + /// + public static Parsed Parse(ReadOnlySpan body) + { + int pos = 0; + + uint opcode = ReadU32(body, ref pos); + if (opcode != ResponseOpcode) + throw new FormatException( + $"expected CharacterGenerationVerificationResponse opcode 0x{ResponseOpcode:X4}, got 0x{opcode:X8}"); + + uint rawCode = ReadU32(body, ref pos); + if (rawCode != (uint)Code.Ok) + return new Parsed(rawCode, null, null, null); + + uint guid = ReadU32(body, ref pos); + string name = StringReader.ReadString16L(body, ref pos); + uint secondsGreyedOut = ReadU32(body, ref pos); + + return new Parsed(rawCode, guid, name, secondsGreyedOut); + } + + private static uint ReadU32(ReadOnlySpan source, ref int pos) + { + if (source.Length - pos < 4) throw new FormatException("truncated u32"); + uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos)); + pos += 4; + return value; + } +} diff --git a/src/AcDream.Core.Net/Messages/CharacterCreate.cs b/src/AcDream.Core.Net/Messages/CharacterCreate.cs new file mode 100644 index 00000000..56aecaeb --- /dev/null +++ b/src/AcDream.Core.Net/Messages/CharacterCreate.cs @@ -0,0 +1,319 @@ +using AcDream.Core.Net.Packets; + +namespace AcDream.Core.Net.Messages; + +/// +/// Retail character-creation request (opcode 0xF656). Campaign CC +/// slice CC2 — the outbound half of retail creation; the shared 0xF643 +/// response is (see that type's doc +/// comment for the two-family opcode collision with +/// , and WorldSession's awaiting-request +/// latch for how the two are disambiguated on receipt). +/// +/// +/// Wire layout ported byte-for-byte from +/// Proto_UI::SendCharGenResult@0x00546a70 (packs the account name, +/// then calls ACCharGenResult::Pack@0x005c7570 → +/// ACCharGenResult::CG_Pack@0x005c7200) and cross-checked against +/// ACE's CharacterCreateInfo.Unpack / Appearance.Unpack +/// (ACE.Entity/CharacterCreateInfo.cs, ACE.Entity/Appearance.cs) +/// and holtburger's CharacterCreateRequestData +/// (holtburger-protocol/src/messages/character/types.rs:236-369), +/// which agree on every field and its order: +/// +/// +/// +/// u32 opcode (0xF656) +/// String16L accountName (packed OUTSIDE CG_Pack, by SendCharGenResult itself) +/// -- ACCharGenResult::CG_Pack body -- +/// u32 constant (always 1 — CG_Pack@0x005c7208) +/// u32 heritage +/// u32 gender +/// u32 eyesStrip +/// u32 noseStrip +/// u32 mouthStrip +/// u32 hairColor +/// u32 eyeColor +/// u32 hairStyle +/// u32 headgearStyle +/// u32 headgearColor +/// u32 shirtStyle +/// u32 shirtColor +/// u32 trousersStyle +/// u32 trousersColor +/// u32 footwearStyle +/// u32 footwearColor +/// f64 skinShade +/// f64 hairShade +/// f64 headgearShade +/// f64 shirtShade +/// f64 trousersShade +/// f64 footwearShade +/// u32 template +/// u32 strength +/// u32 endurance +/// u32 coordination +/// u32 quickness +/// u32 focus +/// u32 self +/// u32 slot (ACE: CharacterSlot — NOT the character guid) +/// u32 classId +/// u32 numSkills (MUST be exactly ) +/// u32[] skillAdvancementClasses (numSkills entries) +/// String16L name +/// u32 startArea +/// u32 isAdmin +/// u32 isEnvoy (ACE: IsSentinel) +/// u32 checksum (see ) +/// +/// +/// +/// The 55-slot invariant. ACE's PlayerFactory.Create +/// (reached from CharacterHandler.CharacterCreateEx) rejects a +/// client/server skill-table mismatch by TERMINATING the session +/// (PlayerFactory.CreateResult.ClientServerSkillsMismatch → +/// session.Terminate(SessionTerminationReason.ClientVersionIncorrect, ...)) +/// — there is no graceful recovery from sending the wrong count. Retail's +/// live skill table has exactly +/// (55) skills, so takes +/// skillAdvancementClasses as a and +/// throws for any length other than 55 — +/// structurally impossible to send anything else through this builder. +/// +/// +/// +/// The trailing checksum. Retail computes and sends it +/// (CG_Pack@0x005c74c3, the final *(uint32_t*)ecx_33 = +/// (ebx_18 + self) store); ACE's CharacterCreateInfo.Unpack never +/// reads it (the reader consumes isSentinel and stops — see +/// ACE.Entity/CharacterCreateInfo.cs:67) and holtburger's +/// CharacterCreateRequestData::unpack agrees (its field list ends at +/// is_sentinel, no checksum read). We compute and send it anyway for +/// byte fidelity with a genuine retail client. Decompiled accumulation +/// order (CG_Pack@0x005c7213-0x005c74c3) sums EXACTLY: +/// heritage, gender, the three appearance strips (eyes/nose/mouth), +/// hairColor, eyeColor, hairStyle, headgearStyle, shirtStyle, trousersStyle, +/// footwearStyle, template, and the six attributes (strength through self). +/// Notably ABSENT from the sum despite being adjacent fields on the wire: +/// headgearColor, shirtColor, trousersColor, footwearColor, all six f64 +/// shades, slot, and classId — mirrors that +/// exact (and exactly that) field set. u32 addition is commutative and +/// associative modulo 2^32, so summation order does not affect the result; +/// orders the terms for readability, not +/// wire fidelity. +/// +/// +/// +/// Routing. Proto_UI::SendCharGenResult sends via +/// Proto_UI::SendToLogon@0x00546b03 — the SAME queue as +/// 's request +/// (Proto_UI::SendDeleteCharacter@0x00546b83, also SendToLogon) +/// and CharacterEnterWorld's request +/// (Proto_UI::SendEnterWorld@0x00546c12). WorldSession's outbound +/// helper, SendCharacterCreation, sends on +/// GameMessageGroup.LoginQueue — the same queue +/// WorldSession.SendDeleteCharacter already uses. +/// +/// +/// +/// Account-name gate. ACE's CharacterCreate handler +/// (CharacterHandler.cs:27-32) silently drops the request when the +/// packed account name doesn't match session.Account — the same +/// silent-no-reply shape 's doc comment already +/// warns about for restore. WorldSession's awaiting-request latch +/// must never assume a reply is coming. +/// +/// +/// +/// ACE double-sends NameInUse (CC2 review F3). +/// CharacterHandler.CharacterCreateEx calls +/// IsCharacterNameAvailable TWICE — once at the top and once after +/// PlayerFactory.Create — and the first callback's return +/// exits only the lambda, so a duplicate name yields TWO 0xF643 +/// NameInUse replies. The first consumes the latch; the second hits +/// WorldSession's unrequested-response drop path (register AD-100) +/// and logs "unexpected CharacterGenerationVerificationResponse". During a +/// connected gate against ACE that log line is EXPECTED after a +/// duplicate-name rejection, not an acdream defect — and CC3's verification +/// gate must not treat the second reply as an error. +/// +/// +public static class CharacterCreate +{ + public const uint Opcode = 0xF656u; + + /// + /// Retail's live skill-advancement-class table size. ACE terminates the + /// session on any other count — see the class doc comment. + /// + public const int SkillAdvancementClassCount = 55; + + /// + /// The fourteen style/color strip fields plus the six f64 shade fields — + /// Appearance.Unpack's exact field set and order + /// (ACE.Entity/Appearance.cs). + /// + public readonly record struct Appearance( + uint EyesStrip, + uint NoseStrip, + uint MouthStrip, + uint HairColor, + uint EyeColor, + uint HairStyle, + uint HeadgearStyle, + uint HeadgearColor, + uint ShirtStyle, + uint ShirtColor, + uint TrousersStyle, + uint TrousersColor, + uint FootwearStyle, + uint FootwearColor, + double SkinShade, + double HairShade, + double HeadgearShade, + double ShirtShade, + double TrousersShade, + double FootwearShade); + + /// The six primary attributes, retail's fixed str/end/coord/quick/focus/self order. + public readonly record struct Attributes( + uint Strength, + uint Endurance, + uint Coordination, + uint Quickness, + uint Focus, + uint Self); + + /// + /// Every field of an outbound CharacterCreate EXCEPT the account name + /// (a separate parameter, packed outside + /// CG_Pack — see the class doc comment) and the skill-advancement + /// array (a parameter so its length is + /// validated at the call site rather than smuggled through a record + /// field of unbounded size). + /// + public readonly record struct Request( + uint Heritage, + uint Gender, + Appearance Appearance, + uint Template, + Attributes Attributes, + uint Slot, + uint ClassId, + string Name, + uint StartArea, + bool IsAdmin, + bool IsEnvoy); + + /// + /// Build the body bytes for an outbound CharacterCreate request. + /// See the class doc comment for the exact byte layout. + /// + /// + /// .Length is not exactly + /// — ACE terminates the session + /// on any other count, so this builder refuses to construct the request + /// at all rather than send something retail-invalid. + /// + public static byte[] BuildRequestBody( + string accountName, + Request request, + ReadOnlySpan skillAdvancementClasses) + { + ArgumentNullException.ThrowIfNull(accountName); + ArgumentNullException.ThrowIfNull(request.Name); + if (skillAdvancementClasses.Length != SkillAdvancementClassCount) + { + throw new ArgumentException( + "retail's CG_Pack numSkills must be exactly " + + $"{SkillAdvancementClassCount} — ACE terminates the session " + + "(PlayerFactory.CreateResult.ClientServerSkillsMismatch) on " + + $"any other count. Got {skillAdvancementClasses.Length}.", + nameof(skillAdvancementClasses)); + } + + Appearance appearance = request.Appearance; + Attributes attributes = request.Attributes; + + var w = new PacketWriter( + 256 + (skillAdvancementClasses.Length * 4) + (request.Name.Length * 2)); + w.WriteUInt32(Opcode); + w.WriteString16L(accountName); + + // -- ACCharGenResult::CG_Pack body -- + w.WriteUInt32(1u); // CG_Pack@0x005c7208 constant + w.WriteUInt32(request.Heritage); + w.WriteUInt32(request.Gender); + w.WriteUInt32(appearance.EyesStrip); + w.WriteUInt32(appearance.NoseStrip); + w.WriteUInt32(appearance.MouthStrip); + w.WriteUInt32(appearance.HairColor); + w.WriteUInt32(appearance.EyeColor); + w.WriteUInt32(appearance.HairStyle); + w.WriteUInt32(appearance.HeadgearStyle); + w.WriteUInt32(appearance.HeadgearColor); + w.WriteUInt32(appearance.ShirtStyle); + w.WriteUInt32(appearance.ShirtColor); + w.WriteUInt32(appearance.TrousersStyle); + w.WriteUInt32(appearance.TrousersColor); + w.WriteUInt32(appearance.FootwearStyle); + w.WriteUInt32(appearance.FootwearColor); + w.WriteDouble(appearance.SkinShade); + w.WriteDouble(appearance.HairShade); + w.WriteDouble(appearance.HeadgearShade); + w.WriteDouble(appearance.ShirtShade); + w.WriteDouble(appearance.TrousersShade); + w.WriteDouble(appearance.FootwearShade); + w.WriteUInt32(request.Template); + w.WriteUInt32(attributes.Strength); + w.WriteUInt32(attributes.Endurance); + w.WriteUInt32(attributes.Coordination); + w.WriteUInt32(attributes.Quickness); + w.WriteUInt32(attributes.Focus); + w.WriteUInt32(attributes.Self); + w.WriteUInt32(request.Slot); + w.WriteUInt32(request.ClassId); + w.WriteUInt32((uint)skillAdvancementClasses.Length); + foreach (uint skill in skillAdvancementClasses) + w.WriteUInt32(skill); + w.WriteString16L(request.Name); + w.WriteUInt32(request.StartArea); + w.WriteUInt32(request.IsAdmin ? 1u : 0u); + w.WriteUInt32(request.IsEnvoy ? 1u : 0u); + w.WriteUInt32(ComputeChecksum(request)); + + return w.ToArray(); + } + + /// + /// Retail's trailing checksum field — see the class doc comment for the + /// exact decompiled accumulation and the fields deliberately absent from + /// it. ACE never reads this field; acdream sends it for byte fidelity + /// with a genuine retail client. + /// + public static uint ComputeChecksum(Request request) + { + Appearance a = request.Appearance; + Attributes b = request.Attributes; + return unchecked( + request.Heritage + + request.Gender + + a.EyesStrip + + a.NoseStrip + + a.MouthStrip + + a.HairColor + + a.EyeColor + + a.HairStyle + + a.HeadgearStyle + + a.ShirtStyle + + a.TrousersStyle + + a.FootwearStyle + + request.Template + + b.Strength + + b.Endurance + + b.Coordination + + b.Quickness + + b.Focus + + b.Self); + } +} diff --git a/src/AcDream.Core.Net/Messages/CharacterDelete.cs b/src/AcDream.Core.Net/Messages/CharacterDelete.cs new file mode 100644 index 00000000..f02caa4d --- /dev/null +++ b/src/AcDream.Core.Net/Messages/CharacterDelete.cs @@ -0,0 +1,89 @@ +using System.Buffers.Binary; +using AcDream.Core.Net.Packets; + +namespace AcDream.Core.Net.Messages; + +/// +/// Retail character-delete request and server acknowledgement, both riding +/// opcode 0xF655. +/// +/// +/// Wire layout ported from retail Proto_UI::SendDeleteCharacter at +/// 0x00546b30: the opcode, then AC1Legacy::PStringBase<char>::Pack +/// of the account id as a String16L, then a trailing u32 written directly +/// after the packed string (*(uint32_t*)var_4 = arg2): +/// +/// +/// +/// u32 opcode (0xF655) +/// String16L accountName +/// u32 characterSlot (NOT the character guid) +/// +/// +/// +/// The caller, CPlayerSystem::DeleteCharacter at 0x0055f830, +/// resolves that trailing u32 from the target character's guid via +/// CharacterSet::GetSlot(persistentData + 4, guid) before sending — +/// retail deletes by **account + SLOT INDEX**, never the character guid. +/// This builder takes the already-resolved slot; resolving a selected +/// character to its slot is Runtime selection-state work (Campaign LA +/// slice LA7b), not this file's job. +/// +/// +/// +/// The server's acknowledgement reuses the same opcode with no trailing +/// payload — ACE's GameMessageCharacterDelete constructs a bare +/// 4-byte body +/// (ACE.Server/Network/GameMessages/Messages/GameMessageCharacterDelete.cs, +/// base constructor called with bodyLength: 4 and no further +/// Writer.Write calls). holtburger's inbound dispatcher +/// (holtburger-protocol/src/messages/game_message/unpack.rs:50-58) +/// disambiguates request vs. ack the identical way we do here — a request +/// has bytes remaining after the opcode, the ack does not. +/// +/// +/// +/// Routing note for LA7b: retail transmits this request via +/// Proto_UI::SendToLogon (the restore request rides +/// SendToControl); ACE sends its acknowledgement and the follow-up +/// refreshed CharacterList on GameMessageGroup.UIQueue. +/// +/// +/// +/// After the ack, ACE immediately follows with a fresh +/// so the roster reflects the character's new pending-delete state +/// (CharacterHandler.CharacterDelete, +/// ACE.Server/Network/Handlers/CharacterHandler.cs:322, inside the +/// SaveCharacter success callback). Requesting and re-rendering that +/// refreshed roster belongs to LA7b's Runtime selection state — this file +/// only builds the request and recognizes the ack. +/// +/// +public static class CharacterDelete +{ + public const uint Opcode = 0xF655u; + + /// + /// Build the body bytes for an outbound CharacterDelete request. + /// Layout: opcode(4) + String16L(accountName) + characterSlot(4). + /// + public static byte[] BuildRequestBody(string accountName, uint characterSlot) + { + ArgumentNullException.ThrowIfNull(accountName); + var w = new PacketWriter(32); + w.WriteUInt32(Opcode); + w.WriteString16L(accountName); + w.WriteUInt32(characterSlot); + return w.ToArray(); + } + + /// + /// Returns whether a complete game-message body is the server's + /// delete acknowledgement — the canonical four-byte opcode-only form + /// ACE emits. A fresh follows separately + /// and is not this method's concern. + /// + public static bool IsAcknowledgement(ReadOnlySpan body) => + body.Length == sizeof(uint) && + BinaryPrimitives.ReadUInt32LittleEndian(body) == Opcode; +} diff --git a/src/AcDream.Core.Net/Messages/CharacterError.cs b/src/AcDream.Core.Net/Messages/CharacterError.cs new file mode 100644 index 00000000..40589d72 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/CharacterError.cs @@ -0,0 +1,279 @@ +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Inbound CharacterError GameMessage (opcode 0xF659) — the +/// server's catch-all failure notice during the pre-world character-select +/// stage (logon conflicts, delete/restore failures, enter-world rejections, +/// subscription state). Today acdream cannot surface ANY character-stage +/// server error to the user; this is the first parser for the family. +/// +/// +/// Wire layout confirmed directly from retail's inbound dispatcher, +/// UIQueueManager::ProcessNetBlobData at 0x0055b000, which +/// reads a u32 immediately after the opcode and passes it to +/// CPlayerSystem::Handle_CharacterError at 0x0055d5d0 typed +/// as enum charError (enum charError eax_86 = *(uint32_t*)((char*)ecx + 4);): +/// +/// +/// +/// u32 opcode (0xF659) +/// u32 errorCode (enum charError) +/// +/// +/// +/// ACE agrees: GameMessageCharacterError +/// (ACE.Server/Network/GameMessages/Messages/GameMessageCharacterError.cs) +/// writes exactly opcode + (uint)error, and every +/// session.SendCharacterError(...) call site in +/// CharacterHandler.cs (the two this slice's +/// / handlers can raise — +/// CharacterError.Delete, CharacterError.LogonServerFull, +/// CharacterError.EnterGameCouldntPlaceCharacter, +/// CharacterError.EnterGameCharacterNotOwned — plus every other +/// value the wider character-stage flow can raise) goes through this same +/// shape. +/// +/// +/// +/// is a verbatim port of retail's enum charError +/// (docs/research/named-retail/acclient.h:4038-4067) — the header's +/// own numeric ground truth, not a subset filtered through ACE's C# port. +/// It is a strict superset of ACE's ACE.Server.Network.Enum.CharacterError +/// (references/ACE/Source/ACE.Server/Network/Enum/CharacterError.cs): +/// retail additionally names 0x2 (LoggedOn), 0x7 (NoPremade), +/// and 0x16 (CharacterIsBooted) — three values ACE omits entirely, +/// none of which ACE's server ever sends but all of which retail's client +/// can receive from a genuine retail server. At 0x8 the port additionally +/// CORRECTS an ACE misnaming: ACE defines 0x8 as ServerCrash2 with a +/// doc comment duplicating 0x4's ID_CHAR_ERROR_SERVER_CRASH text, +/// but retail's header names 0x8 CHAR_ERROR_ACCOUNT_IN_USE — the +/// header wins. Per the project's property-enum-divergence lesson, we port +/// the complete oracle, not just what today's one server implementation +/// emits. ACE's per-value doc +/// comments (themselves sourced from the client's ID_CHAR_ERROR_* +/// string table) are folded in below where they exist. One retail member, +/// FORCE_charError_32_BIT = 0x7FFFFFFF, is a compiler +/// storage-width pragma (MSVC's "force this enum to 32-bit backing store" +/// idiom) and not a real wire value — it is deliberately NOT ported. +/// +/// +/// +/// Unknown values are never rejected: +/// always carries the wire value verbatim, and casting it to +/// (see ) can never throw in +/// C# even for a value retail itself never defined — future server +/// revisions or private servers may add codes we haven't named yet. +/// +/// +public static class CharacterError +{ + public const uint Opcode = 0xF659u; + + /// + /// Verbatim port of retail's enum charError + /// (acclient.h:4038-4067), excluding the 32-bit storage-width + /// sentinel FORCE_charError_32_BIT. + /// + public enum Code : uint + { + /// 0x00 — CHAR_ERROR_UNDEF. + Undefined = 0x00, + + /// + /// 0x01 — CHAR_ERROR_LOGON. ACE: "Cannot have two accounts logged + /// on at the same time." + /// + Logon = 0x01, + + /// 0x02 — CHAR_ERROR_LOGGED_ON. Retail-only; no ACE member. + LoggedOn = 0x02, + + /// + /// 0x03 — CHAR_ERROR_ACCOUNT_LOGON. ACE: "Server could not access + /// your account information. Please try again in a few minutes." + /// + AccountLogon = 0x03, + + /// + /// 0x04 — CHAR_ERROR_SERVER_CRASH. ACE: "The server has + /// disconnected. Please try again in a few minutes." + /// + ServerCrash = 0x04, + + /// 0x05 — CHAR_ERROR_LOGOFF. ACE: "Server could not log off your character." + Logoff = 0x05, + + /// + /// 0x06 — CHAR_ERROR_DELETE. ACE: "Server could not delete your + /// character." Sent by 's + /// server-side handler on every rejection path. + /// + Delete = 0x06, + + /// 0x07 — CHAR_ERROR_NO_PREMADE. Retail-only; no ACE member. + NoPremade = 0x07, + + /// + /// 0x08 — CHAR_ERROR_ACCOUNT_IN_USE. ACE misnames this value + /// ServerCrash2 (its doc comment duplicates 0x04's text); + /// retail's header is the authority. See the class doc comment. + /// + AccountInUse = 0x08, + + /// + /// 0x09 — CHAR_ERROR_ACCOUNT_INVALID. ACE: "The account name you + /// specified was not valid." + /// + AccountInvalid = 0x09, + + /// + /// 0x0A — CHAR_ERROR_ACCOUNT_DOESNT_EXIST. ACE: "The account you + /// specified doesn't exist." + /// + AccountDoesntExist = 0x0A, + + /// + /// 0x0B — CHAR_ERROR_ENTER_GAME_GENERIC. ACE: forces the player + /// back to character-select if in 3D mode; otherwise a no-op OK + /// popup. + /// + EnterGameGeneric = 0x0B, + + /// + /// 0x0C — CHAR_ERROR_ENTER_GAME_STRESS_ACCOUNT. ACE: "You cannot + /// enter the game with a stress creating character." + /// + EnterGameStressAccount = 0x0C, + + /// + /// 0x0D — CHAR_ERROR_ENTER_GAME_CHARACTER_IN_WORLD. ACE: "One of + /// your characters is still in the world. Please try again in a + /// few minutes." + /// + EnterGameCharacterInWorld = 0x0D, + + /// + /// 0x0E — CHAR_ERROR_ENTER_GAME_PLAYER_ACCOUNT_MISSING. ACE: + /// "Server unable to find player account. Please try again + /// later." + /// + EnterGamePlayerAccountMissing = 0x0E, + + /// + /// 0x0F — CHAR_ERROR_ENTER_GAME_CHARACTER_NOT_OWNED. ACE: "You do + /// not own this character." Sent by + /// 's + /// server-side handler when the delete grace window has expired. + /// + EnterGameCharacterNotOwned = 0x0F, + + /// + /// 0x10 — CHAR_ERROR_ENTER_GAME_CHARACTER_IN_WORLD_SERVER. ACE: + /// "One of your characters is currently in the world. Please try + /// again later. This is likely an internal server error." + /// + EnterGameCharacterInWorldServer = 0x10, + + /// + /// 0x11 — CHAR_ERROR_ENTER_GAME_OLD_CHARACTER. ACE: forces the + /// player back to character-select if in 3D mode; no-op + /// otherwise. + /// + EnterGameOldCharacter = 0x11, + + /// + /// 0x12 — CHAR_ERROR_ENTER_GAME_CORRUPT_CHARACTER. ACE: "This + /// character's data has been corrupted. Please delete it and + /// create a new character." + /// + EnterGameCorruptCharacter = 0x12, + + /// + /// 0x13 — CHAR_ERROR_ENTER_GAME_START_SERVER_DOWN. ACE: "This + /// character's starting server is experiencing difficulties. + /// Please try again in a few minutes." + /// + EnterGameStartServerDown = 0x13, + + /// + /// 0x14 — CHAR_ERROR_ENTER_GAME_COULDNT_PLACE_CHARACTER. ACE: + /// "This character couldn't be placed in the world right now. + /// Please try again in a few minutes." Sent by + /// 's + /// server-side handler during a shutdown-in-progress race. + /// + EnterGameCouldntPlaceCharacter = 0x14, + + /// + /// 0x15 — CHAR_ERROR_LOGON_SERVER_FULL. ACE: "Sorry, but the + /// Asheron's Call server is full currently. Please try again + /// later." Sent by both + /// and + /// 's + /// server-side handlers when the world is closed to non-advocates. + /// + LogonServerFull = 0x15, + + /// 0x16 — CHAR_ERROR_CHARACTER_IS_BOOTED. Retail-only; no ACE member. + CharacterIsBooted = 0x16, + + /// + /// 0x17 — CHAR_ERROR_ENTER_GAME_CHARACTER_LOCKED. ACE: "A save of + /// this character is still in progress. Please try again later." + /// + EnterGameCharacterLocked = 0x17, + + /// + /// 0x18 — CHAR_ERROR_SUBSCRIPTION_EXPIRED. ACE: "Your + /// subscription to this game has expired." + /// + SubscriptionExpired = 0x18, + + /// + /// 0x19 — CHAR_ERROR_NUM_ERRORS. Retail's own count-of-errors + /// sentinel (the array-bound idiom, one past the last real code) — + /// never sent on the wire as an actual error. Kept for verbatim + /// completeness of the enum range; do not treat a received 0x19 + /// as meaningful, and LA7b's error-to-string mapping must not + /// render it as a user-facing message. + /// + NumErrors = 0x19, + } + + public readonly record struct Parsed(uint RawErrorCode) + { + /// + /// Best-effort named view of . A plain + /// enum cast never throws in C#, so this is safe even for values + /// retail never defined — always trust + /// as the source of truth. + /// + public Code AsCode => (Code)RawErrorCode; + } + + /// + /// Parse a CharacterError body. must start + /// with the 4-byte opcode (0xF659). + /// + public static Parsed Parse(ReadOnlySpan body) + { + int pos = 0; + + uint opcode = ReadU32(body, ref pos); + if (opcode != Opcode) + throw new FormatException($"expected CharacterError opcode 0x{Opcode:X4}, got 0x{opcode:X8}"); + + uint errorCode = ReadU32(body, ref pos); + return new Parsed(errorCode); + } + + private static uint ReadU32(ReadOnlySpan source, ref int pos) + { + if (source.Length - pos < 4) throw new FormatException("truncated u32"); + uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos)); + pos += 4; + return value; + } +} diff --git a/src/AcDream.Core.Net/Messages/CharacterRestore.cs b/src/AcDream.Core.Net/Messages/CharacterRestore.cs new file mode 100644 index 00000000..8d3caf90 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/CharacterRestore.cs @@ -0,0 +1,148 @@ +using AcDream.Core.Net.Packets; + +namespace AcDream.Core.Net.Messages; + +/// +/// Retail character-restore request (opcode 0xF7D9) and its response +/// (opcode 0xF643). +/// +/// +/// Request — guid-only, an ADAPTATION (register row AD-97). Retail +/// really does send more than the guid. The PDB-paired binary at +/// CPlayerSystem::RestoreCharacter@0x0055d760 is 26 bytes: +/// push 0x008173B4; push 0x008173B4; push guid; +/// call Proto_UI::SendAdminRestoreCharacter@0x00546cf0 — two REAL +/// constant PStringBase<char>* arguments (Binary Ninja renders +/// them as an uninitialized edx local and this; that +/// rendering is the artifact, the two push imm32 are not). +/// SendAdminRestoreCharacter packs both +/// (PStringBase::Pack@0x004fc6f0 emits ≥4 bytes even for an empty +/// string), so retail's request is ≥16 bytes where ours is 8. We send +/// guid-only because ACE +/// (CharacterHandler.CharacterRestore, +/// ACE.Server/Network/Handlers/CharacterHandler.cs:331-385) reads +/// only ReadUInt32() and ignores any tail, and holtburger +/// (holtburger-protocol/src/messages/character/types.rs::CharacterRestoreRequestData, +/// sent from a real client command path) ships guid-only against ACE +/// successfully. The omitted tail is a recorded retail deviation — +/// divergence register AD-97. +/// +/// +/// +/// LA7b hazards. (1) ACE's restore handler has a SILENT no-reply +/// path: an unknown guid hits +/// Characters.SingleOrDefault(...) == null → return; — no 0xF643, +/// no 0xF659. Selection state must never await a restore reply +/// unconditionally. (2) Routing: ACE sends the response on +/// GameMessageGroup.UIQueue; retail transmits the request via +/// Proto_UI::SendToControl (the delete request goes via +/// SendToLogon) — relevant when LA7b picks the outbound queue. +/// +/// +/// +/// u32 opcode (0xF7D9) +/// u32 characterGuid +/// +/// +/// +/// Response — opcode collision with CharacterCreateResponse. ACE's +/// own GameMessageOpcode.cs declares both +/// CharacterCreateResponse = 0xF643 and +/// CharacterRestoreResponse = 0xF643, // This is a duplicate... — a +/// genuine retail opcode reuse, not an ACE bug. GameMessageCharacterRestore +/// (ACE.Server/Network/GameMessages/Messages/GameMessageCharacterRestore.cs) +/// unconditionally writes a success shape: +/// +/// +/// +/// u32 opcode (0xF643) +/// u32 verificationFlag (1 = Ok, matching CharacterGenerationVerificationResponse.Ok) +/// u32 characterGuid +/// String16L characterName +/// u32 secondsGreyedOut +/// +/// +/// +/// But ACE's CharacterRestore handler can ALSO reply on this same +/// opcode via the character-CREATE response path when restore itself fails +/// — TWO real branches: NameInUse (the freed name collided) and +/// Corrupt (SaveCharacter returned false). Both shapes are +/// flag-only, with NO trailing fields +/// (GameMessageCharacterCreateResponse.cs: the guid / name / +/// trailing u32 are only written if (response == ... .Ok)). +/// mirrors that conditionality: the trailing three +/// fields are read only when verificationFlag == 1. Because the two +/// message families are wire-identical when they collide, a caller cannot +/// tell "restore response" from "create response" by opcode or shape +/// alone — it must track which outbound request +/// ( vs. +/// ) +/// it is awaiting a reply to. +/// +/// +/// +/// Campaign CC CC2 update: character creation now exists +/// (), so the +/// disambiguation this doc comment used to defer is real work now, done by +/// WorldSession's awaiting-request latch (set by +/// WorldSession.SendRestoreCharacter / +/// WorldSession.SendCharacterCreation, cleared on the matching +/// response), which routes each 0xF643 to +/// WorldSession.CharacterRestoreReceived or +/// WorldSession.CharacterCreateResponseReceived accordingly and drops +/// (rather than misattributes) a 0xF643 with no outstanding request. The +/// wire parse itself is now shared: delegates to +/// , which both families +/// consume. This type's own shape and +/// signature are UNCHANGED by that refactor — every existing caller and test +/// keeps working exactly as before. +/// +/// +public static class CharacterRestore +{ + public const uint RequestOpcode = 0xF7D9u; + public const uint ResponseOpcode = 0xF643u; + + /// + /// Restore response body. , , and + /// are only populated when + /// equals 1 (Ok) — retail omits them + /// entirely on the wire otherwise (see the collision note above). + /// + public readonly record struct Parsed( + uint VerificationFlag, + uint? Guid, + string? Name, + uint? SecondsGreyedOut) + { + /// True when the trailing character fields are present. + public bool IsOk => VerificationFlag == 1u; + } + + /// + /// Build the body bytes for an outbound CharacterRestore request. + /// Layout: opcode(4) + characterGuid(4). Guid-only — an adaptation of + /// retail's ≥16-byte shape; see the class doc comment and divergence + /// register AD-97. + /// + public static byte[] BuildRequestBody(uint characterGuid) + { + var w = new PacketWriter(8); + w.WriteUInt32(RequestOpcode); + w.WriteUInt32(characterGuid); + return w.ToArray(); + } + + /// + /// Parse a CharacterRestore response body (opcode 0xF643). + /// must start with the 4-byte opcode. Delegates + /// to the shared (Campaign + /// CC CC2); this type's shape and this method's + /// exception behavior are unchanged from before that refactor. + /// + public static Parsed Parse(ReadOnlySpan body) + { + CharGenVerificationResponse.Parsed shared = CharGenVerificationResponse.Parse(body); + return new Parsed(shared.RawCode, shared.Guid, shared.Name, shared.SecondsGreyedOut); + } +} diff --git a/src/AcDream.Core.Net/Messages/ServerName.cs b/src/AcDream.Core.Net/Messages/ServerName.cs new file mode 100644 index 00000000..57a39469 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/ServerName.cs @@ -0,0 +1,90 @@ +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Inbound ServerName GameMessage (opcode 0xF7E1). ACE sends +/// this in the SAME batch as , right after +/// AuthConnectResponse completes — it is the world (server) name the +/// retail character-select screen's "World" box shows. +/// +/// +/// Retail wire path: CM_Login::DispatchUI_WorldInfo@0x006ad860 checks +/// the leading opcode against 0xf7e1, unpacks the trailing +/// PStringBase<char>, and calls +/// ClientUISystem::Handle_Login__WorldInfo@0x005641a0(currentConnections, +/// maxConnections, worldName), which forwards only the name to +/// ECM_Login::SendNotice_WorldName@0x00692b10 (notice id +/// 0x186a2). gmCharacterManagementUI registers for that notice +/// in its ctor (0x004ec8f0) and both +/// RecvNotice_WorldName@0x004ec360 and its own +/// UpdateWorldName@0x004ec120 resolve child element 0x1000039B +/// (UIElement::GetChildRecursive(m_rootField, 0x1000039b), dynamic-cast +/// to UIElement_Text) and call +/// UIElement_Text::SetText(Client::GetInstance()->GetWorldName()) — +/// Client::GetWorldName@0x00401ca0/SetWorldName@0x00402090 just +/// hold the string the notice delivered. The two leading dwords +/// (currentConnections/maxConnections) are read off the wire by the +/// dispatcher but never consumed by the character-management screen itself. +/// +/// +/// +/// ACE: GameMessageOpcode.ServerName = 0xF7E1 +/// (ACE.Server/Network/GameMessages/GameMessageOpcode.cs); +/// GameMessageServerName +/// (ACE.Server/Network/GameMessages/Messages/GameMessageServerName.cs) +/// writes i32 currentConnections, i32 maxConnections, String16L +/// serverName; sent from +/// AuthenticationHandler.SendConnectResponse +/// (ACE.Server/Network/Handlers/AuthenticationHandler.cs:258) +/// alongside GameMessageCharacterList and +/// GameMessageDDDInterrogation. holtburger's +/// ServerNameData +/// (holtburger-protocol/src/messages/character/types.rs) parses the +/// same three fields and cross-checks the field order/types. +/// +/// +/// +/// u32 opcode (0xF7E1) +/// i32 currentConnections +/// i32 maxConnections +/// String16L worldName +/// +/// +public static class ServerName +{ + public const uint Opcode = 0xF7E1u; + + public readonly record struct Parsed( + int CurrentConnections, + int MaxConnections, + string WorldName); + + /// + /// Parse a ServerName body. must start with the + /// 4-byte opcode (0xF7E1) — i.e. pass the full reassembled GameMessage + /// output from . + /// + public static Parsed Parse(ReadOnlySpan body) + { + int pos = 0; + + uint opcode = ReadU32(body, ref pos); + if (opcode != Opcode) + throw new FormatException($"expected ServerName opcode 0x{Opcode:X4}, got 0x{opcode:X8}"); + + int currentConnections = unchecked((int)ReadU32(body, ref pos)); + int maxConnections = unchecked((int)ReadU32(body, ref pos)); + string worldName = StringReader.ReadString16L(body, ref pos); + + return new Parsed(currentConnections, maxConnections, worldName); + } + + private static uint ReadU32(ReadOnlySpan source, ref int pos) + { + if (source.Length - pos < 4) throw new FormatException("truncated u32"); + uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos)); + pos += 4; + return value; + } +} diff --git a/src/AcDream.Core.Net/Packets/PacketWriter.cs b/src/AcDream.Core.Net/Packets/PacketWriter.cs index f7edd92a..54e633a1 100644 --- a/src/AcDream.Core.Net/Packets/PacketWriter.cs +++ b/src/AcDream.Core.Net/Packets/PacketWriter.cs @@ -95,6 +95,13 @@ public sealed class PacketWriter _position += 4; } + public void WriteDouble(double value) + { + EnsureCapacity(8); + BinaryPrimitives.WriteDoubleLittleEndian(_buffer.AsSpan(_position), value); + _position += 8; + } + /// Pad with zeros so the buffer length is a multiple of 4. public void AlignTo4() { diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 6833825b..e135a6cf 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -12,6 +12,19 @@ using AcDream.Core.Net.Transport; namespace AcDream.Core.Net; +/// +/// EnterWorld was rejected by the server while the transport remains a valid +/// character-select session. Callers may surface and let +/// the user choose another character instead of tearing down the connection. +/// +public sealed class CharacterSelectionRejectedException( + CharacterError.Parsed error) + : InvalidOperationException( + $"The server rejected character entry with error 0x{error.RawErrorCode:X8}.") +{ + public CharacterError.Parsed Error { get; } = error; +} + internal interface IWorldSessionTransport : IDisposable { void Send(ReadOnlySpan datagram); @@ -576,6 +589,33 @@ public sealed class WorldSession : IDisposable /// Raised every time the state machine transitions. public event Action? StateChanged; + /// + /// Pre-world character-management replies. All are decoded on the same + /// caller thread and in the same fragment order as ordinary world events. + /// ACE routes these replies on UIQueue; the queue is consumed by the + /// transport before this typed boundary. + /// + public event Action? CharacterListReceived; + public event Action? CharacterDeleteAcknowledged; + public event Action? CharacterRestoreReceived; + /// + /// Campaign CC CC2: fires when a 0xF643 + /// () response arrives while + /// this session's awaiting-request latch says Create — i.e. the + /// reply to . See + /// 's doc comment for the + /// opcode collision with and how + /// the two are disambiguated. + /// + public event Action? CharacterCreateResponseReceived; + public event Action? CharacterErrorReceived; + /// + /// Campaign LA gate round 2 finding 3: ACE sends this in the same batch + /// as (right after + /// AuthConnectResponse) — see . + /// + public event Action? ServerNameReceived; + /// /// Phase F.1: inbound 0xF7B0 GameEvent dispatcher. Each sub-opcode /// handler is registered here (by GameWindow / UI layer / chat @@ -668,6 +708,68 @@ public sealed class WorldSession : IDisposable public CharacterList.Parsed? Characters { get; private set; } + /// + /// Campaign LA gate round 2 finding 3: last + /// (opcode 0xF7E1) received, mirroring ' shape — + /// ACE sends it in the same batch, right after AuthConnectResponse. + /// + public ServerName.Parsed? ServerInfo { get; private set; } + private CharacterError.Parsed? _lastCharacterSelectionError; + + /// + /// Campaign CC CC2: which outbound character-generation request (if any) + /// this session is awaiting a 0xF643 + /// () reply to. Restore and + /// create requests share that opcode on the wire (see + /// 's doc comment) with no + /// self-describing discriminant, so this latch is the only thing that + /// tells the dispatcher which event to fire. Retail's own discriminator + /// is structurally the same latch: Handle_CharGenVerificationResponse + /// @0x0055E8B0 case 1 branches on + /// GetVerificationState() == PENDING → new CharacterIdentity + + /// AddIdentity (create) versus not-pending → unpack into the existing + /// identity at slot (restore). Set by + /// / + /// immediately before the send; cleared the moment a matching 0xF643 is + /// dispatched (success OR parse failure — a malformed reply must not + /// wedge the latch open forever) and on session teardown + /// (). + /// + /// SCOPE, stated exactly (CC2 review F1): this latch correlates + /// the SINGLE outstanding request. It does NOT refuse overlapping + /// requests — a second send while one is outstanding OVERWRITES the + /// latch and the first request's reply is then delivered to the wrong + /// event. Refusing overlap is the CALLER's job, exactly as in retail: + /// gmCharGenMainUI::DoFinish@0x004e9170 only sends when the + /// verification state is UNDEF (CC3's Runtime verification gate owns + /// that rule here). The overwrite behavior is pinned by + /// WorldSessionCharacterCreationTests so CC3 cannot silently + /// regress against it. + /// + /// Read/written only from the caller's frame thread — the same + /// single-threaded invariant every other per-session field here (e.g. + /// ) relies on; + /// is never invoked concurrently with a + /// send (see 's doc comment — the + /// #260 thread-id probe note; CC2 review F5 corrected this pointer). + /// + private enum PendingCharGenVerificationRequest + { + None, + Restore, + Create, + } + + private PendingCharGenVerificationRequest _pendingCharGenVerification = + PendingCharGenVerificationRequest.None; + + /// + /// One-shot guard so an unexpected 0xF643 (no outstanding create/restore + /// request) logs exactly once per session rather than spamming on a + /// misbehaving or replaying server. + /// + private bool _loggedUnexpectedCharGenVerificationResponse; + private readonly IWorldSessionTransport _net; private long _lastInboundPacketTicks = Stopwatch.GetTimestamp(); private long _lastPingRequestTicks; @@ -1019,6 +1121,22 @@ public sealed class WorldSession : IDisposable SweepTransport(); } if (Characters is null) { Transition(State.Failed); throw new TimeoutException("CharacterList not received"); } + + } + + /// + /// Starts the sole asynchronous receive loop while the session remains at + /// character selection. Graphical hosts call this only when they actually + /// pause before ; immediate and headless entry keep + /// the original blocking handshake pump until ServerReady is accepted. + /// + public void StartCharacterSelectionReceive() + { + if (CurrentState != State.InCharacterSelect) + throw new InvalidOperationException( + "character-selection receive requires InCharacterSelect state"); + + EnsureNetReceiveLoopStarted(); } /// @@ -1031,12 +1149,54 @@ public sealed class WorldSession : IDisposable { if (Characters is null || Characters.Characters.Count == 0) throw new InvalidOperationException("Connect() must complete with a non-empty CharacterList"); - var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10)); EnterWorldSelection selection = SelectCharacterForEnterWorld( Characters, characterIndex); - CharacterList.Character chosen = selection.Character; - _activeCharacterId = chosen.Id; + EnterWorldCore(selection.Character.Id, selection.EnterWorldBody, timeout); + } + + /// + /// Send CharacterEnterWorldRequest and CharacterEnterWorld for the exact + /// (guid, accountName) identity the caller supplies, bypassing the + /// cached roster entirely. Campaign CC slice + /// CC3 review-fix round (F1): the index-based overload above assumes + /// refers to a slot in + /// — true for ordinary character-select entry, + /// but FALSE immediately after a character create. ACE never resends + /// post-create (it only appends server-side + /// and replies with the 0xF643 Ok identity — + /// references/ACE/Source/ACE.Server/Network/Handlers/CharacterHandler.cs:170-172), + /// so entering the newly created character by a re-derived index can + /// throw (zero pre-existing characters) or silently enter the WRONG + /// character (N pre-existing characters, since the caller's display + /// order need not match the wire order). Retail's own + /// CPlayerSystem::LogOnCharacter(gid) is itself guid-based, so + /// this is a more direct port of the same entry point — not a + /// deviation from retail — for the one caller (enter-straight-in after + /// create) that has an exact identity in hand and no reliable index. + /// + /// + /// Retail's own fallback when the freshly created name never appears in + /// its per-frame roster poll (gmCharGenMainUI::Update @ + /// 0x004E8460) bounces the UI back to character management + /// (QueueUIMode(0x1000000a) @ 0x004E85D7). acdream has no + /// analogous fallback here because this entry point is driven directly + /// by the identity carried on the SAME reply that confirms the create + /// succeeded — there is no polling step that could fail to find the + /// name, so there is nothing for a fallback to catch. + /// + /// + public void EnterWorld(uint characterGuid, string accountName, TimeSpan? timeout = null) + { + ArgumentNullException.ThrowIfNull(accountName); + byte[] enterWorldBody = CharacterEnterWorld.BuildEnterWorldBody(characterGuid, accountName); + EnterWorldCore(characterGuid, enterWorldBody, timeout); + } + + private void EnterWorldCore(uint characterGuid, byte[] enterWorldBody, TimeSpan? timeout) + { + var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10)); + _activeCharacterId = characterGuid; Transition(State.EnteringWorld); SendGameMessage(CharacterEnterWorld.BuildEnterWorldRequestBody()); @@ -1045,21 +1205,64 @@ public sealed class WorldSession : IDisposable // the blocking pump (campaign landmine #8): the EnterWorld // CreateObject flood — and any NAK it provokes — precedes the // first Tick(). - bool serverReady = false; - while (DateTime.UtcNow < deadline && !serverReady) + _lastCharacterSelectionError = null; + bool serverReady; + if (_netReceiveTask is null) { - var drained = PumpOnce(out var opcodes); - SweepTransport(); - if (!drained) continue; - foreach (var op in opcodes) - if (op == 0xF7DFu) { serverReady = true; break; } + // Immediate/headless entry deliberately preserves the blocking + // transport pump. Besides matching the established handshake + // contract, Receive supplies the clock edge used by the reliable + // transport's resend/NAK sweep on otherwise quiet connections. + serverReady = false; + while (DateTime.UtcNow < deadline + && !serverReady + && _lastCharacterSelectionError is null) + { + bool drained = PumpOnce(out List opcodes); + SweepTransport(); + if (!drained) + continue; + + foreach (uint opcode in opcodes) + { + if (opcode == 0xF7DFu) + { + serverReady = true; + break; + } + } + } + } + else + { + TimeSpan remaining = deadline - DateTime.UtcNow; + serverReady = remaining > TimeSpan.Zero + && WaitForCharacterLogOffConfirmation( + _inboundQueue.Reader, + remaining, + datagram => + { + var opcodes = new List(); + ProcessDatagram(datagram.Memory, opcodes); + return opcodes.Contains(0xF7DFu) + || _lastCharacterSelectionError is not null; + }, + ReturnInboundDatagram, + SweepTransport, + TimeSpan.FromMilliseconds(25)); + } + if (_lastCharacterSelectionError is { } selectionError) + { + Transition(State.InCharacterSelect); + EnsureNetReceiveLoopStarted(); + throw new CharacterSelectionRejectedException(selectionError); } if (!serverReady) { Transition(State.Failed); throw new TimeoutException("ServerReady not received"); } // CPlayerSystem::LogOnCharacter @ 0x0055F890 passes the account // populated by CharacterSet::UnPack, not the spelling supplied to the // login form. ACE validates this canonical account value. - SendGameMessage(selection.EnterWorldBody); + SendGameMessage(enterWorldBody); // LoginComplete is emitted by the host only after the accepted local // Create has completed its canonical first placement. Sending it at @@ -1067,14 +1270,15 @@ public sealed class WorldSession : IDisposable // Hidden/pink-bubble login state. Transition(State.InWorld); - // Phase A.3: start the background receive thread now that the - // handshake is complete and the session is fully established. - // During Connect() and EnterWorld(), PumpOnce() read directly - // from the socket (blocking). From here on, Tick() drains the - // channel instead. - _netReceiveTask = NetReceiveLoopAsync(); + // A paused selector already owns the socket through the background + // receiver. Immediate/headless entry starts that same sole receiver + // only after its blocking ServerReady handshake has completed. + EnsureNetReceiveLoopStarted(); } + private void EnsureNetReceiveLoopStarted() => + _netReceiveTask ??= NetReceiveLoopAsync(); + internal readonly record struct EnterWorldSelection( CharacterList.Character Character, byte[] EnterWorldBody); @@ -1139,8 +1343,9 @@ public sealed class WorldSession : IDisposable ReturnInboundDatagram(datagram); } processed++; - // Bound ONLY in-world: the handshake uses the blocking PumpOnce path, never Tick - // (the async receive owner starts at Transition(State.InWorld)). + // Bound ONLY in-world: immediate/headless handshakes use blocking + // PumpOnce, while a deliberately paused selector uses Tick without + // an in-world flood budget so management replies drain promptly. // Acks and NAKs are NOT per-packet: the end-of-Tick sweep below emits them on // the scheduler's 2.0 s / 0.6 s gates, and it runs after the budget break, so a // deferred inbound tail never defers a due ack, NAK, or resend. The tail itself @@ -1687,10 +1892,113 @@ public sealed class WorldSession : IDisposable if (!dispatchWorldEvents) continue; - if (op == CharacterList.Opcode && Characters is null) + if (op == CharacterList.Opcode) { - try { Characters = CharacterList.Parse(body); } - catch { /* malformed — ignore and keep draining */ } + CharacterList.Parsed parsed; + try + { + parsed = CharacterList.Parse(body); + } + catch + { + // Malformed management messages do not poison the + // remaining ordered UIQueue fragments. + continue; + } + Characters = parsed; + CharacterListReceived?.Invoke(parsed); + } + else if (op == ServerName.Opcode) + { + ServerName.Parsed parsed; + try + { + parsed = ServerName.Parse(body); + } + catch + { + // Malformed management messages do not poison the + // remaining ordered UIQueue fragments. + continue; + } + ServerInfo = parsed; + ServerNameReceived?.Invoke(parsed); + } + else if (op == CharacterDelete.Opcode + && CharacterDelete.IsAcknowledgement(body)) + { + CharacterDeleteAcknowledged?.Invoke(); + } + else if (op == CharGenVerificationResponse.ResponseOpcode) + { + // Campaign CC CC2: this opcode is a genuine retail reuse + // between CharacterRestore and CharacterCreate responses + // (see CharGenVerificationResponse's doc comment) — the + // awaiting-request latch is the only thing that tells us + // which family a given 0xF643 belongs to. Clear it before + // parsing (not after) so a malformed reply can never leave + // the latch stuck open, awaiting a response that will now + // never come and misattributing whatever arrives next. + PendingCharGenVerificationRequest awaited = _pendingCharGenVerification; + if (awaited == PendingCharGenVerificationRequest.None) + { + if (!_loggedUnexpectedCharGenVerificationResponse) + { + _loggedUnexpectedCharGenVerificationResponse = true; + Console.Error.WriteLine( + "[session] unexpected CharacterGenerationVerificationResponse " + + "(0xF643) with no outstanding create/restore request — dropped."); + } + continue; + } + _pendingCharGenVerification = PendingCharGenVerificationRequest.None; + + if (awaited == PendingCharGenVerificationRequest.Restore) + { + CharacterRestore.Parsed parsed; + try + { + parsed = CharacterRestore.Parse(body); + } + catch + { + continue; + } + CharacterRestoreReceived?.Invoke(parsed); + } + else + { + CharGenVerificationResponse.Parsed parsed; + try + { + parsed = CharGenVerificationResponse.Parse(body); + } + catch + { + continue; + } + CharacterCreateResponseReceived?.Invoke(parsed); + } + } + else if (op == CharacterError.Opcode) + { + CharacterError.Parsed parsed; + try + { + parsed = CharacterError.Parse(body); + } + catch + { + continue; + } + // CharacterError::NumErrors is the enum-count sentinel, not + // a server rejection. Retail never presents it, and treating + // it as an EnterWorld failure would abort either handshake + // pump before a valid ServerReady later in the same packet. + if (parsed.AsCode == CharacterError.Code.NumErrors) + continue; + _lastCharacterSelectionError = parsed; + CharacterErrorReceived?.Invoke(parsed); } else if (op == 0xF7E5u) // DddInterrogation — server asks "what dat list versions do you have?" { @@ -2040,6 +2348,65 @@ public sealed class WorldSession : IDisposable SendGameMessage(gameActionBody); } + /// + /// Send retail CharacterDelete through the login/logon queue. The caller + /// supplies the selected entry's active CharacterSet slot, not its guid. + /// + public void SendDeleteCharacter(string accountName, int activeIndex) + { + ArgumentNullException.ThrowIfNull(accountName); + if (activeIndex < 0) + throw new ArgumentOutOfRangeException(nameof(activeIndex)); + SendGameMessage( + CharacterDelete.BuildRequestBody( + accountName, + checked((uint)activeIndex)), + GameMessageGroup.LoginQueue); + } + + /// + /// Send retail CharacterRestore through the control queue. This is + /// deliberately non-blocking because ACE silently drops unknown guids. + /// Arms the awaiting-request latch as Restore BEFORE the send; + /// the latch correlates the SINGLE outstanding request — a second + /// create/restore sent while this one is outstanding overwrites it, and + /// refusing that overlap is the caller's job (CC3's verification gate). + /// See (Campaign CC + /// CC2). + /// + public void SendRestoreCharacter(uint characterId) + { + _pendingCharGenVerification = PendingCharGenVerificationRequest.Restore; + SendControlMessage(CharacterRestore.BuildRequestBody(characterId)); + } + + /// + /// Send retail CharacterCreate (opcode 0xF656) through the + /// login/logon queue — Proto_UI::SendCharGenResult routes via + /// SendToLogon, the same queue + /// uses (see + /// 's class doc comment). Deliberately + /// non-blocking, matching — ACE + /// silently drops a request whose packed account name doesn't match the + /// session's own account. Arms the awaiting-request latch as + /// Create BEFORE the send; the latch correlates the SINGLE + /// outstanding request — overlap refusal is the caller's job (CC3's + /// verification gate; see + /// ) (Campaign CC CC2). + /// + public void SendCharacterCreation( + string accountName, + CharacterCreate.Request request, + ReadOnlySpan skillAdvancementClasses) + { + byte[] body = CharacterCreate.BuildRequestBody( + accountName, + request, + skillAdvancementClasses); + _pendingCharGenVerification = PendingCharGenVerificationRequest.Create; + SendGameMessage(body, GameMessageGroup.LoginQueue); + } + /// /// Phase I.3: test-only hook. When non-null, /// invokes this instead of writing to the wire. Lets unit tests verify @@ -2049,6 +2416,9 @@ public sealed class WorldSession : IDisposable /// internal Action? GameActionCapture { get; set; } + /// LA7b unit-test seam for queue-sensitive pre-world sends. + internal Action? GameMessageCapture { get; set; } + /// /// Phase B.2: get and increment the game-action sequence counter. /// Call once per outbound movement message; pass the returned value @@ -2895,6 +3265,11 @@ public sealed class WorldSession : IDisposable private void SendGameMessage(byte[] gameMessageBody, GameMessageGroup queue) { + if (GameMessageCapture is { } capture) + { + capture(gameMessageBody, queue); + return; + } // #260 probe: log the send BEFORE the sequence counters are consumed // so the line carries the values this datagram will actually use. The // exception filter below logs a wire-write fault WITHOUT catching it @@ -2982,6 +3357,13 @@ public sealed class WorldSession : IDisposable if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) return; + // Campaign CC CC2: a teardown mid-flight must not leave a stale + // Restore/Create latch behind it — this session object is never + // reused (a fresh WorldSession is constructed per connection + // attempt), but clearing here keeps the invariant "no outstanding + // request survives teardown" true rather than merely true-in-practice. + _pendingCharGenVerification = PendingCharGenVerificationRequest.None; + SessionShutdownPlan shutdown = BuildShutdownPlan( CurrentState, _transportNegotiated, @@ -3180,10 +3562,15 @@ public sealed class WorldSession : IDisposable ChannelReader reader, TimeSpan timeout, Func processAndCheckConfirmation, - Action? release = null) + Action? release = null, + Action? periodicWork = null, + TimeSpan? periodicInterval = null) { ArgumentNullException.ThrowIfNull(reader); ArgumentNullException.ThrowIfNull(processAndCheckConfirmation); + TimeSpan cadence = periodicInterval ?? TimeSpan.FromMilliseconds(25); + if (periodicWork is not null && cadence <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(periodicInterval)); using var timeoutSource = new CancellationTokenSource(timeout); // The deadline is also read straight off the monotonic clock, not only @@ -3223,10 +3610,52 @@ public sealed class WorldSession : IDisposable return true; } - bool canRead = reader.WaitToReadAsync(timeoutSource.Token) - .AsTask() - .GetAwaiter() - .GetResult(); + periodicWork?.Invoke(); + if (timeoutSource.IsCancellationRequested || Expired()) + return false; + + bool canRead; + if (periodicWork is null) + { + canRead = reader.WaitToReadAsync(timeoutSource.Token) + .AsTask() + .GetAwaiter() + .GetResult(); + } + else + { + TimeSpan wait = cadence; + if (bounded) + { + TimeSpan remaining = timeout + - Stopwatch.GetElapsedTime(started); + if (remaining <= TimeSpan.Zero) + return false; + if (remaining < wait) + wait = remaining; + } + + using var sliceSource = + CancellationTokenSource.CreateLinkedTokenSource( + timeoutSource.Token); + sliceSource.CancelAfter(wait); + try + { + canRead = reader.WaitToReadAsync(sliceSource.Token) + .AsTask() + .GetAwaiter() + .GetResult(); + } + catch (OperationCanceledException) + when (!timeoutSource.IsCancellationRequested + && !Expired()) + { + // This cadence is the paused selector's frame edge: + // keep reliable transport work moving even when no + // datagram arrives to wake the inbound queue. + continue; + } + } if (!canRead) return false; } diff --git a/src/AcDream.Core/AcDream.Core.csproj b/src/AcDream.Core/AcDream.Core.csproj index 966b25e1..7c0d4903 100644 --- a/src/AcDream.Core/AcDream.Core.csproj +++ b/src/AcDream.Core/AcDream.Core.csproj @@ -15,6 +15,16 @@ + + diff --git a/src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs b/src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs new file mode 100644 index 00000000..c7629130 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs @@ -0,0 +1,458 @@ +namespace AcDream.Core.CharGen; + +/// +/// The resolved render description +/// produces: a body Setup id plus the composed ObjDesc a mesh builder applies +/// to it (CPhysicsObj::DoObjDescChangesFromDefault @ 0x0050F9B0 is +/// retail's equivalent apply step). The three diagnostic lists let callers +/// (and CC6a's installed-DAT test) verify a selection resolved with no +/// missing dat data without needing to re-walk the composition themselves. +/// +/// +/// The body Setup dat id (0x02......) to build the preview mesh from — +/// gender.SetupId, overridden by the selected hair style's +/// AlternateSetup when it is neither 0 nor retail's INVALID_DID +/// (0xFFFFFFFF — Gear Knight / Undead / Tumerok body variants), in turn +/// overridden outright by 's +/// own alternateSetupIdOverride parameter when THAT is not +/// INVALID_DID (gmCG3DView::Update's own +/// m_alternateSetupID resolution, ~0x004EEA46-0x004EEA53 — see that +/// parameter's doc for why chargen's own Appearance page never actually sets +/// it), falling back to +/// when the resolved id is STILL 0 OR INVALID_DID after all three +/// tiers (retail: CharGenState::GetSetupID @ 0x005C5B22 and +/// gmCG3DView::Update's own check at ~0x004EEA5F both test against +/// INVALID_DID, not zero — acclient.h:39909 types the field as +/// IDClass, whose "unset" value is 0xFFFFFFFF; +/// CPhysicsObj::makeObject(setupId)'s own HUMAN_SETUP_ID fallback, +/// gmCG3DView ctor pseudo-C ~0x004EE79D). +/// +/// +/// gender.BasePaletteId (retail Sex_CG.BasePalette) — the +/// palette a mesh builder should pass as the entity's base, NOT +/// ObjDesc.PaletteId (retail's own on-disk BaseObjDesc.PaletteId +/// field is unused for this purpose; cross-checked against +/// references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:58, +/// which sets PropertyDataId.PaletteBase from sex.BasePalette +/// directly). +/// +/// +/// The composed subpalette/texture/part-swap deltas, in retail's exact +/// application order (see ). +/// +public sealed record ChargenAppearanceResult( + uint SetupId, + uint BasePaletteId, + ChargenObjDesc ObjDesc, + IReadOnlyList MissingPalSetIds, + IReadOnlyList MissingClothingTableIds, + IReadOnlyList ClothingTablesMissingBaseEffectForSetup); + +/// +/// Index→ObjDesc appearance factory: the missing piece the campaign plan's +/// "acdream seams" section names (Appearance building: DollEntityBuilder.Build +/// is index-agnostic but reads a LIVE entity; chargen needs a new index→dat +/// →ObjDesc factory). Pure — no Chorizite types on this type's public +/// surface, matching CC1's ChargenOptions family; PalSet/ClothingTable +/// dat reads are pushed behind / +/// , whose production implementation +/// (AcDream.Content.CharGen.ChargenAppearanceCatalog) does the actual +/// dat work. +/// +/// +/// Ports gmCG3DView::Update @ 0x004EE9D0's ObjDesc rebuild verbatim, +/// in its EXACT append order (verified against the decompiled control flow, +/// not inferred from the UI's tab order or the wire's field order, both of +/// which differ — see the per-slot XML doc below): +/// +/// +/// Base body (Sex_CG.BaseObjDesc). +/// Hair style overlay (HairStyle_CG.ObjDesc), if selected. +/// Clothing, in retail's own order — Headgear, Trousers, Shirt, +/// Footwear (NOT the UI tab order 5/6/7/8 = headgear/shirt/trousers/ +/// footwear, and NOT the wire field order from CC2's 0xF656 builder, +/// which is also headgear/shirt/trousers/footwear). Each slot applies +/// its ClothingBase part/texture overrides unconditionally, then +/// — only when a color is also selected — its dye subpalette via +/// ClothingTable::BuildObjDesc @ 0x005A7900. +/// Eyes strip overlay (bald variant when the selected hair style's +/// Bald flag is set), if selected. +/// Nose strip overlay, if selected. +/// Mouth strip overlay, if selected. +/// Skin subpalette — UNCONDITIONAL, no "if selected" guard in +/// retail (the decompiled block runs every time, unlike every style/ +/// color slot above and below it, which all gate on retail's +/// 0xFFFFFFFF sentinel). +/// Hair color subpalette, if selected. +/// Eye color subpalette, if selected. +/// +/// +public static class ChargenAppearanceFactory +{ + /// + /// Retail's HUMAN_SETUP_ID fallback (ACViewer.Entity.Enum.SetupConst.HumanMale + /// = 0x02000001; the same constant gmCG3DView's ctor and + /// ::Update fall back to when no valid body Setup is resolvable). + /// + public const uint HumanSetupId = 0x02000001u; + + /// + /// Retail's IDClass "unset" sentinel (INVALID_DID, + /// 0xFFFFFFFF — acclient.h:39909). CharGenState::GetSetupID @ + /// 0x005C5B22 and gmCG3DView::Update's own checks + /// (~0x004EEA51/0x004EEA5F) both test a Setup id against THIS value, not + /// zero — a hair style whose AlternateSetup field happens to + /// store this sentinel must be treated as "no override," exactly like + /// zero, or the factory would hand a bogus Setup id to + /// Get<Setup> and produce no preview at all. + /// + private const uint InvalidDid = 0xFFFFFFFFu; + + /// + /// Skin subpalette overlay range, retail's hard-coded literal at + /// gmCG3DView::Update ~0x004EF066-0x004EF07E: real byte offset 0, + /// real color count 192 (0xC0), packed to 's + /// *8 on-disk units as (0, 24). + /// + private const byte SkinRangeOffset = 0; + private const byte SkinRangeNumColors = 24; // 192 / 8 + + /// + /// Hair color subpalette overlay range, retail's hard-coded literal at + /// ~0x004EF0FA-0x004EF116: real offset 192 (0xC0), real count 64 (0x40), + /// packed to (24, 8). + /// + private const byte HairRangeOffset = 24; // 192 / 8 + private const byte HairRangeNumColors = 8; // 64 / 8 + + /// + /// Eye color subpalette overlay range, retail's hard-coded literal at + /// ~0x004EF15A-0x004EF16E: real offset 256 (0x100), real count 64 + /// (0x40), packed to (32, 8). + /// + private const byte EyeRangeOffset = 32; // 256 / 8 + private const byte EyeRangeNumColors = 8; // 64 / 8 + + /// + /// Composes a preview appearance description for one heritage/gender + + /// selection, or returns false when the heritage/gender itself doesn't + /// resolve (mirrors the Try* convention + /// already uses). Never throws on missing PalSet/ClothingTable data — + /// a miss is recorded in the result's diagnostic lists and that single + /// contribution is skipped, matching retail's own "hash miss → no-op, + /// caller never checks BuildObjDesc's return value" behavior. + /// + /// + /// Retail's SECOND body-Setup-override source — gmCG3DView's + /// m_alternateSetupID field (default INVALID_DID, read at + /// gmCG3DView::Update @ ~0x004EEA46-0x004EEA53) — which, when set + /// to anything other than INVALID_DID, REPLACES the hairstyle/ + /// gender-resolved Setup id outright rather than combining with it. + /// Decomp-verified NOT to be a character-creation-time mechanism: + /// every write site for m_alternateSetupID (the Penumbraen-crown + /// and Undead-no-flame variants, ~0x004DFB3F/0x004E0C54/0x004E0D42/ + /// 0x004E0DB1) lives on gmBarberUI — the POST-CREATION barber- + /// shop appearance-editing screen, a wholly separate UI class from + /// character creation's gmCGAppearancePage, which has no + /// m_pOption1Checkbox-equivalent field and never writes + /// m_alternateSetupID anywhere in its own methods (confirmed + /// against every field on gmCGAppearancePage, + /// acclient.h:56373-56428). For chargen's own preview, + /// m_alternateSetupID is therefore ALWAYS INVALID_DID in + /// retail, and this parameter's default () + /// reproduces that exactly — a real, decomp-verified precedence tier is + /// threaded through so a future non-chargen consumer of this same + /// factory (e.g. a barber-shop feature, out of Campaign CC's scope) can + /// supply one, without inventing a UI source chargen's own Appearance + /// page doesn't have. + /// + public static bool TryCompose( + ChargenOptions options, + uint heritageId, + int genderKey, + ChargenAppearanceSelection selection, + IChargenPalSetSource palSets, + IChargenClothingTableSource clothingTables, + out ChargenAppearanceResult result, + uint alternateSetupIdOverride = InvalidDid) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(palSets); + ArgumentNullException.ThrowIfNull(clothingTables); + + result = default!; + if (!options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage) + || !heritage.GendersByKey.TryGetValue(genderKey, out ChargenGenderOptions? gender)) + { + return false; + } + + var missingPalSets = new List(); + var missingClothingTables = new List(); + var absentBaseEffects = new List(); + + // ── 1. body Setup id ──────────────────────────────────────────── + uint setupId = gender.SetupId; + ChargenHairStyle? hairStyle = null; + if (selection.HairStyle != ChargenAppearanceSelection.Unset + && selection.HairStyle < (uint)gender.HairStyles.Count) + { + hairStyle = gender.HairStyles[(int)selection.HairStyle]; + if (hairStyle.AlternateSetup != 0 && hairStyle.AlternateSetup != InvalidDid) + setupId = hairStyle.AlternateSetup; + } + + // gmCG3DView::Update @ ~0x004EEA46-0x004EEA53: m_alternateSetupID, + // when set, REPLACES the hairstyle/gender-resolved id outright — it + // does not combine with it. See alternateSetupIdOverride's own doc + // for why chargen's own Appearance page never actually supplies one. + if (alternateSetupIdOverride != InvalidDid) + setupId = alternateSetupIdOverride; + + if (setupId == 0 || setupId == InvalidDid) + setupId = HumanSetupId; + + // ── 2. ObjDesc accumulation, retail's exact append order ─────── + var subPalettes = new List(); + var textureChanges = new List(); + var animPartChanges = new List(); + + Append(gender.BaseObjDesc, subPalettes, textureChanges, animPartChanges); + if (hairStyle is not null) + Append(hairStyle.ObjDesc, subPalettes, textureChanges, animPartChanges); + + ComposeClothingSlot( + gender.Headgears, selection.HeadgearStyle, + gender.ClothingColors, selection.HeadgearColor, selection.HeadgearShade, + setupId, clothingTables, palSets, + subPalettes, textureChanges, animPartChanges, + missingClothingTables, missingPalSets, absentBaseEffects); + ComposeClothingSlot( + gender.Pants, selection.TrousersStyle, + gender.ClothingColors, selection.TrousersColor, selection.TrousersShade, + setupId, clothingTables, palSets, + subPalettes, textureChanges, animPartChanges, + missingClothingTables, missingPalSets, absentBaseEffects); + ComposeClothingSlot( + gender.Shirts, selection.ShirtStyle, + gender.ClothingColors, selection.ShirtColor, selection.ShirtShade, + setupId, clothingTables, palSets, + subPalettes, textureChanges, animPartChanges, + missingClothingTables, missingPalSets, absentBaseEffects); + ComposeClothingSlot( + gender.Footwear, selection.FootwearStyle, + gender.ClothingColors, selection.FootwearColor, selection.FootwearShade, + setupId, clothingTables, palSets, + subPalettes, textureChanges, animPartChanges, + missingClothingTables, missingPalSets, absentBaseEffects); + + if (selection.EyesStrip != ChargenAppearanceSelection.Unset + && selection.EyesStrip < (uint)gender.EyeStrips.Count) + { + ChargenEyeStrip strip = gender.EyeStrips[(int)selection.EyesStrip]; + bool bald = hairStyle?.Bald == true; + Append(bald ? strip.BaldObjDesc : strip.ObjDesc, subPalettes, textureChanges, animPartChanges); + } + if (selection.NoseStrip != ChargenAppearanceSelection.Unset + && selection.NoseStrip < (uint)gender.NoseStrips.Count) + { + Append(gender.NoseStrips[(int)selection.NoseStrip].ObjDesc, subPalettes, textureChanges, animPartChanges); + } + if (selection.MouthStrip != ChargenAppearanceSelection.Unset + && selection.MouthStrip < (uint)gender.MouthStrips.Count) + { + Append(gender.MouthStrips[(int)selection.MouthStrip].ObjDesc, subPalettes, textureChanges, animPartChanges); + } + + // ── Skin subpalette: UNCONDITIONAL (no selection gate in retail) ─ + ChargenPalSet? skinPalSet = palSets.TryGetPalSet(gender.SkinPalSetId); + if (skinPalSet is null) + { + missingPalSets.Add(gender.SkinPalSetId); + } + else + { + int skinIndex = ChargenPalSetMath.GetPaletteIndex(skinPalSet.PaletteIds.Count, selection.SkinShade); + if (skinIndex >= 0) + { + subPalettes.Add(new ChargenSubPalette( + skinPalSet.PaletteIds[skinIndex], SkinRangeOffset, SkinRangeNumColors)); + } + } + + if (selection.HairColor != ChargenAppearanceSelection.Unset + && selection.HairColor < (uint)gender.HairColors.Count) + { + uint hairPalSetId = gender.HairColors[(int)selection.HairColor]; + ChargenPalSet? hairPalSet = palSets.TryGetPalSet(hairPalSetId); + if (hairPalSet is null) + { + missingPalSets.Add(hairPalSetId); + } + else + { + int hairIndex = ChargenPalSetMath.GetPaletteIndex(hairPalSet.PaletteIds.Count, selection.HairShade); + if (hairIndex >= 0) + { + subPalettes.Add(new ChargenSubPalette( + hairPalSet.PaletteIds[hairIndex], HairRangeOffset, HairRangeNumColors)); + } + } + } + + if (selection.EyeColor != ChargenAppearanceSelection.Unset + && selection.EyeColor < (uint)gender.EyeColors.Count) + { + // Direct Palette id — no PalSet/shade indirection (see ChargenPalSet's doc). + uint eyePaletteId = gender.EyeColors[(int)selection.EyeColor]; + subPalettes.Add(new ChargenSubPalette(eyePaletteId, EyeRangeOffset, EyeRangeNumColors)); + } + + var objDesc = new ChargenObjDesc( + gender.BasePaletteId, + subPalettes.AsReadOnly(), + textureChanges.AsReadOnly(), + animPartChanges.AsReadOnly()); + + result = new ChargenAppearanceResult( + setupId, + gender.BasePaletteId, + objDesc, + missingPalSets.AsReadOnly(), + missingClothingTables.AsReadOnly(), + absentBaseEffects.AsReadOnly()); + return true; + } + + private static void Append( + ChargenObjDesc source, + List subPalettes, + List textureChanges, + List animPartChanges) + { + subPalettes.AddRange(source.SubPalettes); + textureChanges.AddRange(source.TextureChanges); + animPartChanges.AddRange(source.AnimPartChanges); + } + + private static void ComposeClothingSlot( + IReadOnlyList gearOptions, + uint styleIndex, + IReadOnlyList clothingColors, + uint colorIndex, + double shade, + uint bodySetupId, + IChargenClothingTableSource clothingTables, + IChargenPalSetSource palSets, + List subPalettes, + List textureChanges, + List animPartChanges, + List missingClothingTables, + List missingPalSets, + List absentBaseEffects) + { + if (styleIndex == ChargenAppearanceSelection.Unset || styleIndex >= (uint)gearOptions.Count) + return; + + ChargenGearOption gear = gearOptions[(int)styleIndex]; + ChargenClothingTable? table = clothingTables.TryGetClothingTable(gear.ClothingTableId); + if (table is null) + { + missingClothingTables.Add(gear.ClothingTableId); + return; + } + + if (table.BaseEffectsBySetupId.TryGetValue(bodySetupId, out ChargenClothingBaseEffect? baseEffect)) + { + animPartChanges.AddRange(baseEffect.PartChanges); + textureChanges.AddRange(baseEffect.TextureChanges); + } + else + { + absentBaseEffects.Add(gear.ClothingTableId); + } + + if (colorIndex == ChargenAppearanceSelection.Unset || colorIndex >= (uint)clothingColors.Count) + return; + + uint paletteTemplateId = clothingColors[(int)colorIndex]; + if (!table.PaletteTemplatesById.TryGetValue(paletteTemplateId, out ChargenClothingPaletteTemplate? template)) + return; // retail: hash miss on the OUTER palette-template lookup is a silent no-op. + + foreach (ChargenClothingSubPaletteChoice choice in template.Choices) + { + ChargenPalSet? palSet = palSets.TryGetPalSet(choice.PalSetId); + if (palSet is null) + { + // Retail's own inner loop (ClothingTable::BuildObjDesc + // ~0x005A7B24-0x005A7BD3) returns 0 IMMEDIATELY when + // DBObj::Get fails for one subpalEffect entry's PalSet + // (~0x005A7B32) — aborting every REMAINING choice in this + // same garment's palette template, not merely skipping the + // failed one. `break`, not `continue`, matches that; the + // miss is still recorded so callers can see it happened. + missingPalSets.Add(choice.PalSetId); + break; + } + + int index = ChargenPalSetMath.GetPaletteIndex(palSet.PaletteIds.Count, shade); + if (index < 0) + continue; + + uint paletteId = palSet.PaletteIds[index]; + foreach (ChargenClothingSubPaletteRange range in choice.Ranges) + { + subPalettes.Add(new ChargenSubPalette( + paletteId, + PackOffset(range.Offset), + PackNumColors(range.NumColors))); + } + } + } + + /// + /// Converts a real (unpacked) clothing subpalette offset into + /// 's packed *8 on-disk unit. Throws + /// rather than silently truncating on a shape we've never seen and + /// don't know how to represent losslessly (guards against the + /// unchecked-narrowing footgun a plain (byte)(value / 8) cast + /// would otherwise hide). + /// + private static byte PackOffset(uint realOffset) + { + if (realOffset % 8u != 0 || realOffset > 2040u) + { + throw new ArgumentOutOfRangeException( + nameof(realOffset), + realOffset, + "Clothing subpalette range offset does not fit the packed *8 byte " + + "convention (expected a multiple of 8 in [0, 2040])."); + } + return (byte)(realOffset / 8u); + } + + /// + /// Same packing as , plus retail's own explicit + /// "whole palette" sentinel: a packed NumColors of 0 means "the + /// entire palette" ('s + /// doc: "Length=0 is a sentinel meaning entire palette... defaulting to + /// 256*8"). A real count of exactly 2048 (256*8) IS that same value + /// spelled out in real units, so it packs to 0 BY DESIGN — not because + /// an unchecked (byte) cast happens to wrap 256 back to 0. + /// + private static byte PackNumColors(uint realNumColors) + { + if (realNumColors == 2048u) + return 0; + if (realNumColors % 8u != 0 || realNumColors > 2040u) + { + throw new ArgumentOutOfRangeException( + nameof(realNumColors), + realNumColors, + "Clothing subpalette range color count does not fit the packed *8 byte " + + "convention (expected a multiple of 8 in [0, 2040], or exactly 2048 " + + "for the whole-palette sentinel)."); + } + return (byte)(realNumColors / 8u); + } +} diff --git a/src/AcDream.Core/CharGen/ChargenAppearanceOptions.cs b/src/AcDream.Core/CharGen/ChargenAppearanceOptions.cs new file mode 100644 index 00000000..7894374e --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenAppearanceOptions.cs @@ -0,0 +1,41 @@ +namespace AcDream.Core.CharGen; + +/// +/// One hair-style option in a +/// list. Retail schema: HairStyle_CG (nested inside +/// Sex_CG::Serialize @ 0x005C1600). Bald and +/// AlternateSetup handle the Gear Knight / Olthoi bald-head special +/// case ACE's SexCG.GetHeadObject comment documents. +/// +public sealed record ChargenHairStyle( + uint IconId, + bool Bald, + uint AlternateSetup, + ChargenObjDesc ObjDesc); + +/// +/// One eye-strip option. Retail carries a SEPARATE bald variant +/// (BaldIconId / BaldObjDesc) because a bald hairstyle +/// selection changes which eye texture applies — see ACE's +/// SexCG.GetEyeTexture(strip, isBald). +/// +public sealed record ChargenEyeStrip( + uint IconId, + uint BaldIconId, + ChargenObjDesc ObjDesc, + ChargenObjDesc BaldObjDesc); + +/// One nose- or mouth-strip option (retail FaceStrip_CG). +public sealed record ChargenFaceStrip(uint IconId, ChargenObjDesc ObjDesc); + +/// +/// One clothing-slot option (headgear/shirt/pants/footwear). Retail schema: +/// Gear_CG. ClothingTableId resolves through +/// ClothingTable::BuildObjDesc; WeenieDefaultId is the weenie +/// class the character actually receives in inventory on creation (ACE's +/// SexCG.GetHeadgearWeenie family). +/// +public sealed record ChargenGearOption( + string Name, + uint ClothingTableId, + uint WeenieDefaultId); diff --git a/src/AcDream.Core/CharGen/ChargenAppearanceSelection.cs b/src/AcDream.Core/CharGen/ChargenAppearanceSelection.cs new file mode 100644 index 00000000..f26b6427 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenAppearanceSelection.cs @@ -0,0 +1,52 @@ +namespace AcDream.Core.CharGen; + +/// +/// The fourteen style/color indices plus the six f64 shades +/// needs to build a preview +/// description — field-for-field the same shape as CC3's +/// AcDream.Runtime.Session.RuntimeCharacterCreationAppearance (and, +/// through it, CharacterCreate.Appearance's wire fields), kept as a +/// SEPARATE type here rather than referenced directly because +/// AcDream.Runtime depends on AcDream.Core and not the other +/// way around. CC6b's job is the trivial field-by-field copy from the +/// Runtime owner's snapshot into this type. / +/// mirror retail's own sentinels exactly (same +/// citations CC3 already recorded): 0xFFFFFFFF for "nothing selected" +/// and the IEEE-754 -1.0 construction-time shade default +/// (CharGenState::Reset @ 0x005C68A0). +/// +public readonly record struct ChargenAppearanceSelection( + uint EyesStrip, + uint NoseStrip, + uint MouthStrip, + uint HairStyle, + uint HairColor, + uint EyeColor, + uint HeadgearStyle, + uint HeadgearColor, + uint ShirtStyle, + uint ShirtColor, + uint TrousersStyle, + uint TrousersColor, + uint FootwearStyle, + uint FootwearColor, + double SkinShade, + double HairShade, + double HeadgearShade, + double ShirtShade, + double TrousersShade, + double FootwearShade) +{ + public const uint Unset = 0xFFFFFFFFu; + public const double UnsetShade = -1.0; + + public static ChargenAppearanceSelection Default { get; } = new( + Unset, Unset, Unset, + Unset, Unset, Unset, + Unset, Unset, + Unset, Unset, + Unset, Unset, + Unset, Unset, + UnsetShade, UnsetShade, UnsetShade, + UnsetShade, UnsetShade, UnsetShade); +} diff --git a/src/AcDream.Core/CharGen/ChargenAttributeMath.cs b/src/AcDream.Core/CharGen/ChargenAttributeMath.cs new file mode 100644 index 00000000..2b7b6807 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenAttributeMath.cs @@ -0,0 +1,56 @@ +namespace AcDream.Core.CharGen; + +/// +/// Pure port of retail's attribute-credit budget math +/// (CharGenState::SetHeritageGroup @ 0x005C67A0 and the six +/// attribute-slider setters around 0x005C46CE..0x005C494E, all of +/// the shape remainingAtrbCredits = totalAtrbCredits - (str + end + +/// coord + quick + focus + self)): a heritage's AttributeCredits +/// is the total budget the SIX RAW attribute values (each already including +/// its 10-point floor) must sum to exactly — not a budget of points spent +/// above the floor. Retail's Finish gate +/// (gmCharGenMainUI::DoFinish @ 0x004E9170, line +/// if (arg2 != 0 && eax->remainingAtrbCredits > 0)) aborts +/// creation with a warning dialog whenever credits remain unspent — retail +/// forces a full spend. ACE's server does not re-validate this; acdream +/// ports the CLIENT gate (see the campaign plan's Finish section). +/// +public static class ChargenAttributeMath +{ + /// Retail's CharGenState::Reset @ 0x005C68A0 + /// this->atrbMin = 0xa — every attribute's floor. + public const int AttributeMin = 10; + + /// Retail's CharGenState::Reset + /// this->atrbMax = 0x64 — every attribute's ceiling. + public const int AttributeMax = 100; + + /// attributeCreditBudget - values.Total. Zero means the + /// budget is exactly spent; positive means credits remain (Finish must + /// refuse); this port never expects negative (retail's own slider + /// clamping through ConstrainAllByHeritage prevents overspend, + /// but callers building a candidate outside that UI path should treat a + /// negative result as an invalid state, not silently accept it). + public static int RemainingCredits(uint attributeCreditBudget, ChargenAttributeValues values) => + checked((int)attributeCreditBudget) - values.Total; + + /// Retail's Finish gate: creation may proceed only when this is + /// true. + public static bool IsFullySpent(uint attributeCreditBudget, ChargenAttributeValues values) => + RemainingCredits(attributeCreditBudget, values) == 0; + + /// True when a single attribute value falls within + /// .. inclusive. + public static bool IsWithinRange(int value) => value >= AttributeMin && value <= AttributeMax; + + /// True when every one of the six attributes falls within + /// range individually (does not check the credit total — see + /// for that). + public static bool AreAllWithinRange(ChargenAttributeValues values) => + IsWithinRange(values.Strength) + && IsWithinRange(values.Endurance) + && IsWithinRange(values.Coordination) + && IsWithinRange(values.Quickness) + && IsWithinRange(values.Focus) + && IsWithinRange(values.Self); +} diff --git a/src/AcDream.Core/CharGen/ChargenAttributeValues.cs b/src/AcDream.Core/CharGen/ChargenAttributeValues.cs new file mode 100644 index 00000000..21a35a47 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenAttributeValues.cs @@ -0,0 +1,22 @@ +namespace AcDream.Core.CharGen; + +/// +/// The six primary attributes in retail's chargen wire/serialization order +/// (Strength, Endurance, Coordination, Quickness, Focus, Self) — matches +/// both Template_CG::Serialize @ 0x005C0450 and the 0xF656 +/// ACCharGenData::CG_Pack @ 0x005C7200 attribute block the plan +/// documents. Used both for a preset 's fixed +/// spread and, by CC3's Runtime owner, as the candidate values a "Custom" +/// profession is actively assigning via the six attribute sliders +/// (0x100003e6..eb). +/// +public readonly record struct ChargenAttributeValues( + int Strength, + int Endurance, + int Coordination, + int Quickness, + int Focus, + int Self) +{ + public int Total => Strength + Endurance + Coordination + Quickness + Focus + Self; +} diff --git a/src/AcDream.Core/CharGen/ChargenClothingTable.cs b/src/AcDream.Core/CharGen/ChargenClothingTable.cs new file mode 100644 index 00000000..3601bf48 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenClothingTable.cs @@ -0,0 +1,147 @@ +using System.Collections.Frozen; + +namespace AcDream.Core.CharGen; + +/// +/// One un-resolved dye-shade choice inside a clothing "palette template" +/// (retail's inner CloSubpalEffect array entry, one per +/// ClothingTable::BuildObjDesc @ 0x005A7900 loop iteration; Chorizite +/// projects the identical shape as DatReaderWriter.Types.CloSubPalette +/// — a PaletteSet id plus a list of overlay ranges). Offsets/counts +/// here are the REAL (unpacked) color units read straight off the dat +/// (installed-DAT probe: Aluvian male "Cloth Cap" headgear reads +/// off=2000,n=48 for every one of its 28 palette-template entries) — the +/// *8-packed byte convention only applies to the OUTPUT +/// , converted once at composition time +/// (). +/// +public readonly record struct ChargenClothingSubPaletteRange(uint Offset, uint NumColors); + +/// +/// One resolvable-by-shade colour choice for a clothing palette template: +/// the PalSet id (0x0F......) to resolve via +/// , plus every overlay range +/// to apply once resolved. +/// +public readonly record struct ChargenClothingSubPaletteChoice( + uint PalSetId, + IReadOnlyList Ranges); + +/// +/// One clothing-table "palette template" (retail's CloPaletteTemplate, +/// looked up in ClothingTable::_paletteTemplatesHash by the id +/// CharGenState::GetHeadgearPaletteTemplateID (and its Shirt/Trousers/ +/// Footwear siblings, all at 0x005C38F0-0x005C3980) return — which is itself +/// just a bounds-checked passthrough of Sex_CG.ClothingColors[index]: +/// every one of the four per-slot template-id arrays +/// (headgearPaletteTemplateIDs/shirtPaletteTemplateIDs/ +/// trousersPaletteTemplateIDs/footwearPaletteTemplateIDs) is +/// populated from the SAME single Sex_CG::ClothingColors dat field — +/// there is no per-clothing-slot color list in the dat schema at all. This +/// CONFIRMS (does not merely approximate) register row AP-208's shared-list +/// design in RuntimeCharacterCreationAppearance/ +/// ChargenAppearanceSlot — installed-DAT probe: Aluvian male's +/// ClothingColors = {9,6,4,8,7,5,2,3,13}, and the "Cloth Cap" +/// headgear's ClothingSubPalEffects keys include 2,3,4,5,6,7,8,9,13 — +/// the shared list's raw values ARE the template-id keys, verified live. +/// +public sealed record ChargenClothingPaletteTemplate( + IReadOnlyList Choices) +{ + public static ChargenClothingPaletteTemplate Empty { get; } = + new(Array.Empty()); +} + +/// +/// One body-Setup-specific part/texture override set (retail's +/// ClothingBaseEffect, applied by +/// ClothingBase::ApplyPartAndTextureChanges @ 0x005A8EB0): for each +/// CloObjectEffect, an unconditional +/// (part index → replacement GfxObj) plus every +/// the SAME object effect carries for +/// that part. +/// +public sealed record ChargenClothingBaseEffect( + IReadOnlyList PartChanges, + IReadOnlyList TextureChanges) +{ + public static ChargenClothingBaseEffect Empty { get; } = new( + Array.Empty(), + Array.Empty()); +} + +/// +/// Pure projection of one ClothingTable dat object (0x19......, retail +/// ClothingTable::Unpack / Chorizite +/// DatReaderWriter.DBObjs.ClothingTable). One instance is referenced +/// per — a single garment +/// CHOICE (e.g. "Cloth Cowl") carries its own table covering every body +/// Setup it can be worn on plus every dye choice offered for it. +/// +/// +/// Deliberate scope cut (CC6a) — MEASURED, not just asserted: retail's +/// ClothingTable::BuildObjDesc falls back through a chain of ~8 +/// hard-coded Setup-id substitutions (Umbraen crown/no-crown/void, +/// Penumbraen, Undead skeleton/zombie, Anakshay) when +/// has no direct entry for the requested +/// body Setup. CC6a's composer looks up +/// directly and skips a slot's part/texture contribution on a miss (this is +/// the OUTER lookup — ClothingTable::_cloBaseHash — whose retail +/// miss behavior is genuinely a no-op the caller never checks; the SEPARATE +/// inner per-choice PalSet lookup inside the same function's subpalette loop +/// has its own, stricter, abort-on-miss behavior — see +/// ChargenAppearanceFactory.ComposeClothingSlot's own doc, ported +/// faithfully there) rather than porting the Setup-substitution chain. The +/// installed-DAT catalog test (ChargenAppearanceCatalogInstalledDatTests) +/// MEASURED this directly across all 26 heritage/gender combinations rather +/// than assuming it: for the 9 standard heritages where retail's own UI +/// actually shows clothing controls (everything except Gear Knight and the +/// two Olthoi variants, which retail hides the clothes button for entirely +/// — gmCGAppearancePage::Update @ 0x0047E8F0's +/// m_pClothesButton->SetVisible(0) branches for +/// mHeritageGroup == 6 and == 0xc || == 0xd), the default +/// gear choices resolve against their own body Setup with ZERO missing +/// coverage. Undead IS a real gap — retail DOES show clothing +/// controls for Undead, and MEASURED coverage is missing for ALL FOUR +/// clothing slots (headgear, trousers, shirt, AND footwear — not just three +/// of the four), on both genders: neither gender's live body Setup has a +/// entry in any of its four default gear +/// choices' clothing tables, because Undead's live body Setup IS one of the +/// skeleton/zombie variants the un-ported substitution chain exists to +/// redirect. A live preview for Undead will therefore render its default +/// clothing selection with NO part/texture override applied on any of the +/// four slots (the underlying body shows through unclothed) until the +/// substitution chain — or an equivalent per-heritage default-clothing-setup +/// mapping — lands. Filed as a known CC6a limitation for CC6b/a follow-up +/// rather than silently "confirmed unreachable." +/// +/// +public sealed record ChargenClothingTable( + IReadOnlyDictionary BaseEffectsBySetupId, + IReadOnlyDictionary PaletteTemplatesById) +{ + public static ChargenClothingTable Empty { get; } = new( + FrozenDictionary.Empty, + FrozenDictionary.Empty); +} + +/// +/// Resolves a PalSet dat id (0x0F......) to its pure projection. The +/// production implementation (AcDream.Content.CharGen.ChargenAppearanceCatalog) +/// reads and caches the real dat object; this interface keeps +/// free of any Chorizite dependency +/// (unit tests supply a hand-built fake). +/// +public interface IChargenPalSetSource +{ + ChargenPalSet? TryGetPalSet(uint palSetId); +} + +/// +/// Resolves a ClothingTable dat id (0x19......) to its pure projection. +/// Same production/test split as . +/// +public interface IChargenClothingTableSource +{ + ChargenClothingTable? TryGetClothingTable(uint clothingTableId); +} diff --git a/src/AcDream.Core/CharGen/ChargenGenderOptions.cs b/src/AcDream.Core/CharGen/ChargenGenderOptions.cs new file mode 100644 index 00000000..da79e60c --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenGenderOptions.cs @@ -0,0 +1,65 @@ +namespace AcDream.Core.CharGen; + +/// +/// Per-gender chargen options for one heritage: base body model plus every +/// appearance option list the Appearance page's spin/color controls +/// (0x100003af..b8, 0x1000030e..0x10000321) enumerate. Retail schema: +/// Sex_CG::Serialize @ 0x005C1600 (ACE's SexCG.Unpack mirrors +/// the same field order). GenderKey is the raw key from +/// HeritageGroupCG.Genders (retail/ACE both key this as a small int — +/// carried through unmapped rather than assumed 0=male/1=female, confirmed +/// live by CC1's installed-DAT tests). +/// +public sealed record ChargenGenderOptions( + int GenderKey, + string Name, + uint Scale, + uint SetupId, + uint SoundTableId, + uint IconId, + uint BasePaletteId, + uint SkinPalSetId, + uint PhysicsTableId, + uint MotionTableId, + uint CombatTableId, + ChargenObjDesc BaseObjDesc, + IReadOnlyList HairColors, + IReadOnlyList HairStyles, + IReadOnlyList EyeColors, + IReadOnlyList EyeStrips, + IReadOnlyList NoseStrips, + IReadOnlyList MouthStrips, + IReadOnlyList Headgears, + IReadOnlyList Shirts, + IReadOnlyList Pants, + IReadOnlyList Footwear, + IReadOnlyList ClothingColors) +{ + /// + /// True when AT LEAST ONE of the eight lists below is non-empty (an OR + /// across all eight, not a per-list guarantee). Deliberately omits + /// , , and + /// — CC6's color-wheel controls need those + /// three independently of this property and must check them + /// separately. CC1's installed-DAT gate + /// (ChargenTableReaderInstalledDatTests.InstalledHeritages_EachHasAtLeastOneGenderWithNonEmptyAppearanceOptions) + /// only proves "at least one gender per heritage has at least one + /// non-empty list among these eight" — it does NOT prove every list is + /// non-empty for every gender of every heritage, and it does not cover + /// the three color lists at all; see + /// ChargenTableReaderInstalledDatTests.InstalledHeritages_AppearanceOptionListsRecordedPerListCompleteness + /// for the per-list installed-DAT reality. A gender missing an + /// individual option list can still be a valid data shape (e.g. a + /// bald-only heritage's hair styles), so callers building UI must still + /// defend against empty lists individually. + /// + public bool HasAnyAppearanceOptions => + HairStyles.Count > 0 + || EyeStrips.Count > 0 + || NoseStrips.Count > 0 + || MouthStrips.Count > 0 + || Headgears.Count > 0 + || Shirts.Count > 0 + || Pants.Count > 0 + || Footwear.Count > 0; +} diff --git a/src/AcDream.Core/CharGen/ChargenHeritageGroup.cs b/src/AcDream.Core/CharGen/ChargenHeritageGroup.cs new file mode 100644 index 00000000..b69cf525 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenHeritageGroup.cs @@ -0,0 +1,31 @@ +namespace AcDream.Core.CharGen; + +/// +/// Retail's 11 standard player heritages plus the two Olthoi player-race +/// variants (ACE's loader comment on CharGen.Unpack: "HERITAGE +/// GROUPS -- 11 standard player races and 2 Olthoi"). Numeric values match +/// ACE's ACE.Entity.Enum.HeritageGroup exactly — the same ids the +/// wire uses and the same ids that key +/// . Display names come from the +/// DAT's own HeritageGroupCG.Name string, not this enum — this enum +/// exists only for callers that need to branch on a KNOWN heritage +/// identity (e.g. CC6's Olthoi-vs-human camera offsets, per the campaign +/// plan's 3D-preview recon). +/// +public enum ChargenHeritageGroup : uint +{ + Invalid = 0, + Aluvian = 1, + Gharundim = 2, + Sho = 3, + Viamontian = 4, + Shadowbound = 5, + Gearknight = 6, + Tumerok = 7, + Lugian = 8, + Empyrean = 9, + Penumbraen = 10, + Undead = 11, + Olthoi = 12, + OlthoiAcid = 13, +} diff --git a/src/AcDream.Core/CharGen/ChargenHeritageOptions.cs b/src/AcDream.Core/CharGen/ChargenHeritageOptions.cs new file mode 100644 index 00000000..a97f3d03 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenHeritageOptions.cs @@ -0,0 +1,32 @@ +namespace AcDream.Core.CharGen; + +/// +/// Everything the Heritage/Profession/Skills/Appearance/Town pages need for +/// one heritage. Retail schema: HeritageGroup_CG::Serialize @ +/// 0x005C2100 (ACE's HeritageGroupCG.Unpack mirrors the same +/// field order). PrimaryStartAreaIndices / SecondaryStartAreaIndices +/// index into the CharGen table's SHARED ChargenOptions.StarterAreas +/// list, not a per-heritage list of their own. +/// +public sealed record ChargenHeritageOptions( + uint HeritageId, + string Name, + uint IconId, + uint SetupId, + uint EnvironmentSetupId, + uint AttributeCredits, + uint SkillCredits, + IReadOnlyList PrimaryStartAreaIndices, + IReadOnlyList SecondaryStartAreaIndices, + IReadOnlyDictionary SkillCostsBySkillId, + IReadOnlyList Templates, + IReadOnlyDictionary GendersByKey) +{ + /// True for the two Olthoi player-race variants (ids 12/13) — + /// CC6's 3D preview hard-codes a different camera target position for + /// these (gmCGAppearancePage::Update @ 0x0047E8F0, the + /// mHeritageGroup == 0xc || mHeritageGroup == 0xd branch). + public bool IsOlthoi => + HeritageId == (uint)ChargenHeritageGroup.Olthoi + || HeritageId == (uint)ChargenHeritageGroup.OlthoiAcid; +} diff --git a/src/AcDream.Core/CharGen/ChargenObjDesc.cs b/src/AcDream.Core/CharGen/ChargenObjDesc.cs new file mode 100644 index 00000000..11af5931 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenObjDesc.cs @@ -0,0 +1,41 @@ +namespace AcDream.Core.CharGen; + +/// +/// One palette overlay range inside a . Retail +/// applies NumColors * 8 colors from SubPaletteId starting at +/// Offset * 8 in the base palette (Chorizite.ACProtocol.Types.Subpalette +/// docs; the live-session equivalent is +/// ). +/// +public readonly record struct ChargenSubPalette(uint SubPaletteId, byte Offset, byte NumColors); + +/// One texture-map override inside a . +/// PartIndex identifies which GfxObj part's surface list the swap +/// applies to. +public readonly record struct ChargenTextureChange(byte PartIndex, uint OldTextureId, uint NewTextureId); + +/// One animated-part swap override inside a . +public readonly record struct ChargenAnimPartChange(byte PartIndex, uint PartId); + +/// +/// Presentation-free projection of Chorizite's ObjDesc shape (retail's +/// CObjDesc): the palette id plus the overlay/texture/part-swap deltas +/// a live appearance is built from. Used both for a gender's base body +/// () and for every appearance +/// option's own overlay (hair styles, eye/nose/mouth strips) — CC6's +/// index→ObjDesc appearance factory composes these the same way retail's +/// ClothingTable::BuildObjDesc / DoObjDescChangesFromDefault +/// pipeline does. +/// +public sealed record ChargenObjDesc( + uint PaletteId, + IReadOnlyList SubPalettes, + IReadOnlyList TextureChanges, + IReadOnlyList AnimPartChanges) +{ + public static ChargenObjDesc Empty { get; } = new( + 0u, + Array.Empty(), + Array.Empty(), + Array.Empty()); +} diff --git a/src/AcDream.Core/CharGen/ChargenOptions.cs b/src/AcDream.Core/CharGen/ChargenOptions.cs new file mode 100644 index 00000000..cd631e48 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenOptions.cs @@ -0,0 +1,72 @@ +using System.Collections.Frozen; +using System.Diagnostics.CodeAnalysis; + +namespace AcDream.Core.CharGen; + +/// +/// Top-level, presentation-free, immutable projection of retail's CharGen +/// DAT table (portal.dat 0x0E000002, ACCharGenData::Serialize @ +/// 0x005C36D0) PLUS the global SkillTable (portal.dat 0x0E000004) that +/// retail falls back to when a heritage's own skill-cost list has no entry +/// for a given skill id. Retail's ACCharGenData::GetSkillTrainedCost @ +/// 0x005C26D0 / GetSkillSpecializedCost @ 0x005C27D0 both scan +/// the heritage's own list first and, on a miss (or an empty list), fall +/// through to DBCache::GetFromEnumStatic(4, 2, 0x10000004) — the +/// SAME global SkillTable every other skill-cost lookup in the client +/// reads — rather than treating the skill as free or invalid. See +/// . Production builds create this +/// from the installed DAT through Content's +/// AcDream.Content.CharGen.ChargenTableReader.Load; this type itself +/// has no DAT/Chorizite dependency so it is safe to hand to plugin-facing +/// or test code. Every collection is frozen/immutable at construction (a +/// caller cannot downcast an +/// back to a mutable and mutate this +/// process-shared model out from under other readers). Everything a "typed +/// chargen options model" needs — starter areas, heritages, templates, +/// per-gender appearance option lists, skill costs — hangs off this one +/// root. +/// +/// +/// Campaign CC gate round 1 closeout (Group 2): the global SkillTable's +/// MinLevel/Description/Formula per skill (see 's +/// own doc for why these are GLOBAL-only, unlike +/// which also has a per-heritage counterpart). Defaults to null (not an +/// empty dictionary) so every pre-existing caller that builds a +/// without this parameter — five test fixtures +/// plus ChargenOptions.Empty below — compiles and behaves exactly as +/// before; treats null the same as "empty." +/// +public sealed record ChargenOptions( + IReadOnlyList StarterAreas, + IReadOnlyDictionary HeritagesById, + IReadOnlyDictionary GlobalSkillCostsBySkillId, + IReadOnlyDictionary? GlobalSkillDetailsBySkillId = null) +{ + public static ChargenOptions Empty { get; } = new( + Array.Empty(), + FrozenDictionary.Empty, + FrozenDictionary.Empty); + + public bool TryGetHeritage(uint heritageId, [MaybeNullWhen(false)] out ChargenHeritageOptions heritage) => + HeritagesById.TryGetValue(heritageId, out heritage); + + public bool TryGetStarterArea(int index, [MaybeNullWhen(false)] out ChargenStarterArea area) + { + if (index >= 0 && index < StarterAreas.Count) + { + area = StarterAreas[index]; + return true; + } + area = default; + return false; + } + + /// See 's own doc. + public bool TryGetSkillDetail(uint skillId, [MaybeNullWhen(false)] out ChargenSkillDetail detail) + { + if (GlobalSkillDetailsBySkillId is { } details && details.TryGetValue(skillId, out detail)) + return true; + detail = default; + return false; + } +} diff --git a/src/AcDream.Core/CharGen/ChargenPalSet.cs b/src/AcDream.Core/CharGen/ChargenPalSet.cs new file mode 100644 index 00000000..e822d92c --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenPalSet.cs @@ -0,0 +1,23 @@ +namespace AcDream.Core.CharGen; + +/// +/// Pure projection of a PalSet dat object (0x0F......, retail +/// PalSet::Unpack / Chorizite DatReaderWriter.DBObjs.PalSet): +/// the ordered list of Palette dat ids (0x04......) a shade fraction picks +/// from via . Every appearance +/// color slot that resolves "by shade" — skin (ChargenGenderOptions.SkinPalSetId), +/// hair (ChargenGenderOptions.HairColors[i]), and every clothing +/// dye choice (ChargenClothingSubPaletteChoice.PalSetId) — reads one +/// of these. Eye color is the one exception: retail uses the raw entry +/// from ChargenGenderOptions.EyeColors directly as a Palette id, no +/// PalSet/shade indirection (gmCG3DView::Update pseudo-C ~0x004EF12F; +/// cross-checked against +/// references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:100, +/// which sets EyesPalette straight from sex.EyeColorList[eyeColor] +/// with no GetPaletteID call, unlike the Skin/Hair lines immediately +/// above it). +/// +public sealed record ChargenPalSet(IReadOnlyList PaletteIds) +{ + public static ChargenPalSet Empty { get; } = new(Array.Empty()); +} diff --git a/src/AcDream.Core/CharGen/ChargenPalSetMath.cs b/src/AcDream.Core/CharGen/ChargenPalSetMath.cs new file mode 100644 index 00000000..2076013b --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenPalSetMath.cs @@ -0,0 +1,57 @@ +namespace AcDream.Core.CharGen; + +/// +/// Pure port of retail's shade→palette-index resolution +/// (PalSet::GetPaletteID @ 0x005AC570, invoked from +/// gmCG3DView::Update @ 0x004EE9D0 for the skin/hair subpalette +/// build and from ClothingTable::BuildObjDesc @ 0x005A7900 for every +/// clothing-slot dye choice). The decompiled body is genuinely FPU-elided — +/// the _ftol2() truncating-cast operand is lost to the decompiler, +/// and can only be read as "some product of -ish and +/// -ish operands" from the surrounding x87 stack +/// traffic — but the decomp's own control-flow SHAPE is still verifiable +/// independent of that lost operand: a two-sided FPU compare at +/// 0x005AC5A0 gating on >= 0.0, consistent with a +/// [0,1] shade bounds check before the cast. What resolves the +/// elided operand is ACE's ACE.DatLoader.FileTypes.PaletteSet.GetPaletteID, +/// which carries the explicit comment "Taken from acclient.c +/// (PalSet::GetPaletteID)" against the exact formula below. That is TWO +/// sources (decomp control flow + ACE's cited port), not three: the +/// PaletteSet.cs file present in the vendored ACViewer checkout is +/// ACE's own file, not an independent reimplementation, and ACViewer's +/// ClothingTableList.xaml.cs:97 UI slider computes a DIFFERENT +/// expression for a DIFFERENT problem (mapping a shade back to a slider tick +/// position against Shades.Maximum, i.e. count-1, not +/// count) — neither corroborates this formula and both are dropped +/// from the evidence chain here. +/// +public static class ChargenPalSetMath +{ + /// + /// Resolves a shade fraction to an index into a palette-id list of the + /// given . Returns -1 (retail's + /// INVALID_DID outcome) when is + /// non-positive or falls outside + /// [0.0, 1.0] — including retail's own -1.0 "unset" + /// sentinel (CharGenState::Reset @ 0x005C68A0), which is + /// deliberately out of range so an untouched shade resolves to + /// "nothing," matching retail. Callers should treat -1 as "skip this + /// subpalette contribution" rather than emit a placeholder id. + /// + public static int GetPaletteIndex(int count, double shade) + { + if (count <= 0 || shade < 0.0 || shade > 1.0) + return -1; + + // Truncating cast, exactly as ACE's cited port and the decomp's + // _ftol2() (which truncates toward zero on x86, matching a plain + // C-style (int) cast here since count > 0 and 0 <= shade <= 1 keep + // the product non-negative). + int index = (int)((count - 0.000001) * shade); + if (index < 0) + index = 0; + if (index > count - 1) + index = count - 1; + return index; + } +} diff --git a/src/AcDream.Core/CharGen/ChargenSkillAdvancement.cs b/src/AcDream.Core/CharGen/ChargenSkillAdvancement.cs new file mode 100644 index 00000000..cb6a4b3a --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenSkillAdvancement.cs @@ -0,0 +1,127 @@ +namespace AcDream.Core.CharGen; + +/// +/// Retail's four skill states. Wire values match ACE's +/// ACE.Entity.Enum.SkillAdvancementClass exactly (0=Inactive, +/// 1=Untrained, 2=Trained, 3=Specialized) — ACE unpacks the 0xF656 +/// CharacterCreateInfo.SkillAdvancementClasses list with this same +/// numbering, and retail's CharGenState::UpdateRemainingSkillCredits @ +/// 0x005C37C0 only charges credits for Trained (2) and Specialized (3). +/// +public enum ChargenSkillAdvancementClass : uint +{ + Inactive = 0, + Untrained = 1, + Trained = 2, + Specialized = 3, +} + +/// +/// One skill's retail training cost for a heritage. Retail schema: +/// SkillCG, entries of HeritageGroupCG.Skills. +/// PrimaryCost is the TOTAL cost to reach Specialized (not an +/// increment on top of NormalCost) — retail's +/// UpdateRemainingSkillCredits adds exactly one of the two per +/// skill, never both. +/// +public readonly record struct ChargenSkillCost(uint SkillId, int NormalCost, int PrimaryCost); + +/// +/// gmCGSkillsPage::MakeSkillFormula @0x00480e10's six raw inputs — +/// retail's SkillFormula struct (acclient.h) verbatim field +/// order/shape: _w=, +/// _x=, +/// _y=, _z=, +/// _attr1=, _attr2=. +/// / are the raw 1-6 retail +/// attribute id (matching AcDream.Runtime.Session.ChargenAttributeId's +/// own numbering exactly — Strength=1..Self=6) rather than that enum type +/// itself, since Core does not (and must not) reference Runtime; the App +/// layer, which already references both, does the enum cast at the one +/// call site that needs an attribute NAME. +/// +public readonly record struct ChargenSkillFormula( + int AdditiveBonus, + int Attribute1Multiplier, + int Attribute2Multiplier, + int Divisor, + uint Attribute1, + uint Attribute2); + +/// +/// One skill's GLOBAL (heritage-independent) presentation data — retail's +/// SkillBase._min_level/_description/_formula fields, +/// sourced ONLY from the portal.dat SkillTable. Distinct from +/// (which exists BOTH per-heritage +/// (SkillCG) AND globally) because these three fields have NO +/// per-heritage override in retail at all — SkillCG (the per- +/// heritage cost record HeritageGroupCG.Skills projects) carries +/// only Id/NormalCost/PrimaryCost, verified against the +/// DatReaderWriter binding. +/// +/// +/// Retail's _min_level is typed SKILL_ADVANCEMENT_CLASS, not a +/// character level — gmCGSkillsPage::UpdateSkillEntry @0x00480bf0's +/// own bucket test (arg2->iMinlevel <= 1) reads it as "the +/// lowest at which this skill is +/// USEABLE" — <= 1 (Inactive/Untrained) means useable while +/// untrained, == 2 (Trained) means training is required first. +/// +public readonly record struct ChargenSkillDetail( + uint SkillId, + uint MinLevel, + string Description, + ChargenSkillFormula Formula); + +/// +/// Retail's fixed-size per-character skill-advancement array +/// (CharGenState.skillLevels). ACE's CharacterCreateInfo.Unpack +/// terminates the connection if the wire's numSkills count is not +/// exactly (55): retail's own loop in +/// UpdateRemainingSkillCredits walks indices 1..totalNumSkills +/// (skipping reserved slot 0), and Chorizite's SkillId enum runs +/// 1..54 — 54 real skills plus the reserved slot 0 is exactly 55. This type +/// makes that shape structural: it always holds exactly 55 slots, so a +/// caller building the 0xF656 body (CC2) cannot accidentally send a +/// different count. +/// +public sealed class ChargenSkillAdvancementSet +{ + /// Slot 0 is reserved (unused by retail); slots 1..54 map 1:1 + /// to Chorizite's DatReaderWriter.Enums.SkillId values. + public const int SlotCount = 55; + + private readonly ChargenSkillAdvancementClass[] _slots = new ChargenSkillAdvancementClass[SlotCount]; + + /// Skill state by raw skill id. Ids outside 1..54 read + /// as and cannot be + /// set. + public ChargenSkillAdvancementClass this[uint skillId] + { + get => skillId >= 1 && skillId < SlotCount + ? _slots[skillId] + : ChargenSkillAdvancementClass.Inactive; + set + { + if (skillId < 1 || skillId >= SlotCount) + throw new ArgumentOutOfRangeException( + nameof(skillId), + skillId, + $"Skill id must be in 1..{SlotCount - 1}."); + _slots[skillId] = value; + } + } + + /// + /// Materializes the wire body shape: exactly + /// entries, slot 0 first, matching ACE's + /// CharacterCreateInfo.SkillAdvancementClasses read order. + /// + public IReadOnlyList ToWireClasses() + { + var wire = new uint[SlotCount]; + for (int i = 0; i < SlotCount; i++) + wire[i] = (uint)_slots[i]; + return wire; + } +} diff --git a/src/AcDream.Core/CharGen/ChargenSkillCreditMath.cs b/src/AcDream.Core/CharGen/ChargenSkillCreditMath.cs new file mode 100644 index 00000000..019c07e7 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenSkillCreditMath.cs @@ -0,0 +1,78 @@ +namespace AcDream.Core.CharGen; + +/// +/// Pure port of retail's skill-credit spend calculation +/// (CharGenState::UpdateRemainingSkillCredits @ 0x005C37C0): walk +/// every skill slot, add NormalCost for Trained or PrimaryCost +/// for Specialized (never both), and subtract the total from the heritage's +/// SkillCredits budget. No DAT/DatReaderWriter dependency — callers +/// (CC3's Runtime owner) pass in the heritage's already-projected +/// lookup plus the global SkillTable +/// fallback lookup (). +/// +public static class ChargenSkillCreditMath +{ + /// + /// Total credits spent across every Trained/Specialized skill in + /// . Retail's cost lookup + /// (ACCharGenData::GetSkillTrainedCost @ 0x005C26D0 / + /// GetSkillSpecializedCost @ 0x005C27D0) is TWO-TIERED: it scans + /// the active heritage's own list + /// first, and only on a miss falls through to the global SkillTable + /// (, portal.dat 0x0E000004 via + /// DBCache::GetFromEnumStatic(4, 2, 0x10000004)). A skill id + /// missing from BOTH tiers is retail's -1/"no cost" case; this port + /// treats that as uncostable and skips it (mirrors ACE's identical + /// precedence in + /// references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:184-196, + /// which seeds from SkillTable.SkillBaseHash[i] and then applies + /// a heritage override). + /// + public static int ComputeSpent( + ChargenSkillAdvancementSet advancement, + IReadOnlyDictionary costsBySkillId, + IReadOnlyDictionary globalCostsBySkillId) + { + ArgumentNullException.ThrowIfNull(advancement); + ArgumentNullException.ThrowIfNull(costsBySkillId); + ArgumentNullException.ThrowIfNull(globalCostsBySkillId); + + int spent = 0; + for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++) + { + ChargenSkillAdvancementClass cls = advancement[skillId]; + if (cls != ChargenSkillAdvancementClass.Trained + && cls != ChargenSkillAdvancementClass.Specialized) + { + continue; + } + + if (!costsBySkillId.TryGetValue(skillId, out ChargenSkillCost cost) + && !globalCostsBySkillId.TryGetValue(skillId, out cost)) + { + continue; + } + + spent += cls == ChargenSkillAdvancementClass.Specialized + ? cost.PrimaryCost + : cost.NormalCost; + } + return spent; + } + + /// + /// totalSkillCredits - ComputeSpent(...) — retail's + /// remainingSkillCredits. Retail's Finish gate (DoFinish @ + /// 0x004E91F2-adjacent) only checks remainingAtrbCredits > 0 + /// for attributes; skill credits are NOT required to hit exactly zero + /// (unspent skill credits are simply lost on creation) — callers should + /// not port an "exact spend" gate for skills the way + /// does for attributes. + /// + public static int RemainingCredits( + uint totalSkillCredits, + ChargenSkillAdvancementSet advancement, + IReadOnlyDictionary costsBySkillId, + IReadOnlyDictionary globalCostsBySkillId) => + checked((int)totalSkillCredits) - ComputeSpent(advancement, costsBySkillId, globalCostsBySkillId); +} diff --git a/src/AcDream.Core/CharGen/ChargenStarterArea.cs b/src/AcDream.Core/CharGen/ChargenStarterArea.cs new file mode 100644 index 00000000..e4161e73 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenStarterArea.cs @@ -0,0 +1,23 @@ +using System.Numerics; + +namespace AcDream.Core.CharGen; + +/// One spawn point inside a . Retail +/// schema: Position nested inside StartingArea +/// (ACCharGenData::Serialize @ 0x005C36D0). +public readonly record struct ChargenPosition(uint CellId, Vector3 Origin, Quaternion Orientation); + +/// +/// One named starting area (a town/region a heritage may spawn a new +/// character in) with its candidate spawn points. The CharGen table holds +/// ONE shared list of these — +/// and SecondaryStartAreaIndices reference this list by index, they +/// do not carry their own copies. Retail schema: +/// ACCharGenData::Serialize @ 0x005C36D0 (ACE's loader comment names +/// this StarterArea; Chorizite names the DAT type StartingArea +/// — same shape). +/// +public sealed record ChargenStarterArea( + int Index, + string Name, + IReadOnlyList Locations); diff --git a/src/AcDream.Core/CharGen/ChargenSwatchColor.cs b/src/AcDream.Core/CharGen/ChargenSwatchColor.cs new file mode 100644 index 00000000..4f9d6586 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenSwatchColor.cs @@ -0,0 +1,46 @@ +namespace AcDream.Core.CharGen; + +/// +/// A resolved representative swatch color — the RGB byte triple retail's +/// Palette::get_color32 @0x0053e050 (a direct ARGB[index] +/// read, no bounds check on the real client) yields for one fixed sample +/// index into a Palette dat object's (0x04......) color table. Alpha is +/// deliberately omitted: retail's swatch/gradient recolor path +/// (SurfaceWindow::ReplaceColor / +/// SurfaceWindow::BlitAndColor(..., Blit_Multiply, ...)) only ever +/// reads R/G/B out of the sampled color — gmCGAppearancePage's +/// m_tColorWheel entries carry iRed/iGreen/iBlue +/// fields and no iAlpha at all (DoColorSpots @0x0047d850, +/// DoGradDisk @0x0047da90). +/// +public readonly record struct ChargenSwatchRgb(byte R, byte G, byte B); + +/// +/// Resolves one Palette dat object (0x04......) to a representative color +/// at a fixed sample index — retail's +/// ClientCharGenState::GetColorFromPal @0x00563990 +/// (DBObj::Get(QualifiedDataID(id, PALETTE_TYPE=0xa)) then +/// Palette::get_color32(index), i.e. a direct, unchecked +/// ARGB[index] read). The production implementation +/// (AcDream.Content.CharGen.ChargenAppearanceCatalog) reads and +/// caches the real dat object, matching 's +/// established Core/Content split (interface in Core, Chorizite-backed +/// implementation in Content); unit tests supply a hand-built fake so this +/// interface's only consumer, , +/// stays free of any Chorizite dependency. +/// +public interface IChargenPaletteColorSource +{ + /// + /// Returns false when the Palette dat object itself doesn't resolve, OR + /// when falls outside its color table — + /// retail's own Palette::get_color32 has NO bounds check (a + /// genuinely unchecked ARGB[index] read), so this is a + /// deliberate defensive divergence: acdream cannot reproduce retail's + /// undefined-behavior read as safe managed code, and treats an + /// out-of-range sample the same as a missing palette (no representative + /// color, caller skips the contribution) rather than throwing or + /// fabricating a value. + /// + bool TryGetColor(uint paletteId, int index, out ChargenSwatchRgb color); +} diff --git a/src/AcDream.Core/CharGen/ChargenSwatchColorResolver.cs b/src/AcDream.Core/CharGen/ChargenSwatchColorResolver.cs new file mode 100644 index 00000000..f958c9ef --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenSwatchColorResolver.cs @@ -0,0 +1,197 @@ +namespace AcDream.Core.CharGen; + +/// +/// Campaign CC gate round 1 Batch G (R2-5, register AP-216/AP-217's +/// remaining halves): ports the color-computation half of +/// gmCGAppearancePage::SetSelection @0x0047e260 — the per-swatch +/// representative RGB retail stores into m_tColorWheel[i].iRed/iGreen/ +/// iBlue before DoColorSpots @0x0047d850 paints it and +/// DoGradDisk @0x0047da90 tints the gradient disc with the CURRENTLY +/// selected swatch's own entry. +/// +/// +/// Two distinct color-source shapes, both decomp-traced: +/// +/// +/// +/// PalSet-averaged (Hair / Nose+Mouth+Skin / Headgear / Shirt / +/// Trousers / Footwear). Retail resolves ONE PalSet id per swatch +/// index, then — for EVERY Palette id inside that PalSet (its num_pals +/// sub-palettes/shades) — samples a FIXED index via +/// GetColorFromPal and averages the R/G/B channels +/// (@0x0047e759-0x0047e80f, the shared loop every non-Eyes case +/// jumps into at label_47e74b). The averaging is intentional: the +/// swatch shows one representative hue for a color CHOICE that actually +/// spans several shade variants, not any single shade. +/// +/// +/// Direct (Eyes only). Retail uses the raw entry from +/// ChargenGenderOptions.EyeColors directly as a Palette id — no +/// PalSet indirection, no averaging, one GetColorFromPal call per +/// swatch (@0x0047e3bf-0x0047e40f), matching +/// 's own doc for why Eyes is the one +/// exception to the PalSet convention everywhere else in this campaign. +/// +/// +/// +/// +/// Clothing's PalSet id source (Headgear/Shirt/Trousers/Footwear) is +/// itself retail's own second-order lookup: CharGenState::SetHeadgearStyle +/// @0x005c5350 (and its Shirt/Trousers/Footwear siblings, +/// @0x005c5470/0x005c5590/0x005c56b0) call +/// StoreColorInformation @0x005c44d0 against the NEWLY SELECTED +/// garment's own ClothingTable (DBObj::Get(clothingTableId, 0x19)) +/// every time the style changes, walking that table's own +/// CloPaletteTemplate hash table and recording — for every template +/// id that ALSO appears in the gender's shared +/// list — that template's +/// FIRST sub-palette choice's PalSet id +/// (headgearPalSetIDs[]/shirtPalSetIDs[]/etc, offset +0x10 +/// off the copied CloPaletteTemplate, a decompiler-elided field read +/// cross-checked against this codebase's own +/// ChargenClothingSubPaletteChoice.PalSetId — the first +/// Choices entry of the SAME projected shape). +/// reproduces the OBSERVABLE +/// result (which PalSet a given +/// index represents for the currently equipped garment) via a direct +/// dictionary lookup by template id rather than replicating retail's own +/// array-building traversal — a hash-table walk's OWN internal bucket order +/// is an implementation detail of retail's cache, not part of the +/// observable behavior, and a by-id lookup is provably order-independent. +/// This keeps the swatch index space IDENTICAL to what +/// ChargenAppearanceFactory.ComposeClothingSlot already treats as +/// canonical (gender.ClothingColors[(int)colorIndex], the SAME +/// index space RuntimeCharacterCreationAppearance persists and the +/// 3D preview already renders correctly from, proven across this +/// campaign's own installed-DAT and live two-client gates) — deliberately +/// NOT re-deriving a second, potentially-divergent index space from +/// StoreColorInformation's own cache-building order. +/// +/// +public static class ChargenSwatchColorResolver +{ + /// Hair's fixed sample index (gmCGAppearancePage::SetSelection + /// case ECG_PARTS_HAIR, __return = 0xd0 @0x0047e388). + public const int HairSampleIndex = 0xd0; + + /// Nose/Mouth/Skin's shared fixed sample index — all three route + /// to the SAME single-entry PalSet id (ChargenGenderOptions.SkinPalSetId) + /// with __return = 0xb0 (Nose @0x0047e488, Mouth @0x0047e4e9, Skin + /// @0x0047e542). + public const int SkinFamilySampleIndex = 0xb0; + + /// Eyes' fixed sample index, used DIRECTLY against + /// ChargenGenderOptions.EyeColors[i] with no PalSet indirection + /// (case ECG_PARTS_EYES, GetColorFromPal(..., 0x103) + /// @0x0047e3e2). + public const int EyeSampleIndex = 0x103; + + /// The shared clothing sample index — Headgear/Shirt/Trousers/ + /// Footwear all set __return = 0x520 before falling into the + /// shared averaging loop (@0x0047e5be/0x0047e62b/0x0047e6ab/0x0047e733). + public const int ClothingSampleIndex = 0x520; + + /// + /// PalSet-averaged representative color (Hair / Nose+Mouth+Skin / + /// Headgear / Shirt / Trousers / Footwear) — retail's shared + /// label_47e74b loop: resolve the PalSet, sample every one of its + /// Palette ids at , average the R/G/B + /// channels. A PalSet that resolves but carries zero Palette ids + /// reproduces retail's own explicit zero-init with no averaging + /// division (@0x0047e790 zeroes iRed/iGreen/iBlue + /// unconditionally before the num_pals > 0 guard) — returns + /// true with a BLACK color, not false, matching retail's actual output + /// for that shape. Returns false only when the PalSet id itself doesn't + /// resolve at all (retail's outer if (__return_8 != 0) miss, + /// which leaves that swatch's m_tColorWheel entry untouched from + /// whatever it held before — the closest acdream equivalent is "no + /// color to paint this swatch with"). + /// + public static bool TryGetPalSetAverageColor( + IChargenPalSetSource palSets, + IChargenPaletteColorSource colors, + uint palSetId, + int sampleIndex, + out ChargenSwatchRgb color) + { + ArgumentNullException.ThrowIfNull(palSets); + ArgumentNullException.ThrowIfNull(colors); + + color = default; + ChargenPalSet? palSet = palSets.TryGetPalSet(palSetId); + if (palSet is null) + return false; + + if (palSet.PaletteIds.Count == 0) + return true; // retail: explicit zero-init, no division — black. + + int sumR = 0, sumG = 0, sumB = 0; + foreach (uint paletteId in palSet.PaletteIds) + { + // A per-entry miss contributes (0,0,0) to the running sum — + // retail's own loop (@0x0047e7a5-0x0047e7dc) accumulates + // unconditionally and always divides by the FULL num_pals + // afterward; GetColorFromPal's own miss path + // (@0x005639b5) returns 0 rather than skipping the entry. + if (colors.TryGetColor(paletteId, sampleIndex, out ChargenSwatchRgb c)) + { + sumR += c.R; + sumG += c.G; + sumB += c.B; + } + } + + int count = palSet.PaletteIds.Count; + color = new ChargenSwatchRgb((byte)(sumR / count), (byte)(sumG / count), (byte)(sumB / count)); + return true; + } + + /// + /// Direct representative color (Eyes only) — one + /// call against + /// with no PalSet indirection and no + /// averaging, matching retail's ECG_PARTS_EYES case exactly. + /// + public static bool TryGetDirectColor( + IChargenPaletteColorSource colors, + uint paletteId, + int sampleIndex, + out ChargenSwatchRgb color) + { + ArgumentNullException.ThrowIfNull(colors); + return colors.TryGetColor(paletteId, sampleIndex, out color); + } + + /// + /// Resolves the PalSet id a clothing swatch index represents for the + /// CURRENTLY EQUIPPED garment — see this class's own doc for why a + /// direct by-id lookup reproduces retail's observable + /// StoreColorInformation result without replicating its own + /// cache-building traversal order. Returns false when the garment's + /// ClothingTable doesn't resolve, has no palette template for + /// , or that template carries no + /// sub-palette choices at all (an authored garment with a dye slot but + /// literally zero dye options) — every case retail's own miss paths + /// treat as "this swatch has no color." + /// + public static bool TryGetClothingSwatchPalSetId( + IChargenClothingTableSource clothingTables, + uint clothingTableId, + uint paletteTemplateId, + out uint palSetId) + { + ArgumentNullException.ThrowIfNull(clothingTables); + + palSetId = 0; + ChargenClothingTable? table = clothingTables.TryGetClothingTable(clothingTableId); + if (table is null) + return false; + if (!table.PaletteTemplatesById.TryGetValue(paletteTemplateId, out ChargenClothingPaletteTemplate? template)) + return false; + if (template.Choices.Count == 0) + return false; + + palSetId = template.Choices[0].PalSetId; + return true; + } +} diff --git a/src/AcDream.Core/CharGen/ChargenTemplate.cs b/src/AcDream.Core/CharGen/ChargenTemplate.cs new file mode 100644 index 00000000..484b12a4 --- /dev/null +++ b/src/AcDream.Core/CharGen/ChargenTemplate.cs @@ -0,0 +1,41 @@ +namespace AcDream.Core.CharGen; + +/// +/// One profession preset offered on the Profession page. Retail's +/// gmCGProfessionPage::UpdateProfession @ 0x004821b0 switches on +/// CharGenState.template_ and resolves BOTH the button to highlight +/// AND the description string from the SAME 0..6 index: 0 → button +/// 0x100003d9 / ID_CharGen_CustomText ("Custom"), 1 → 0x100003da +/// (Bow Hunter), 2 → 0x100003df (Swashbuckler), 3 → 0x100003db (Life +/// Caster), 4 → 0x100003dc (War Caster), 5 → 0x100003dd (Wayfarer), 6 → +/// 0x100003de (Soldier). "Custom" is therefore template index 0, NOT a +/// special UI-only mode with no data — it is a real +/// entry (confirmed against the installed DAT: each human heritage ships +/// this row as "Adventurer", sitting at the attribute floor rather than +/// spending the full credit budget). The seven profession buttons all wire +/// to CharGenState::SetTemplate(state, N, 1) @ 0x005C5A60 (N = +/// 0..6, the second arg a "commit" flag); SetTemplate writes +/// template_ = N and, because N != 0xffffffff (retail's +/// no-template sentinel — never sent by any button), immediately calls +/// CharGenState::ApplyTemplate @ 0x005C5080, which re-reads that +/// template row's six attributes and skill list and re-applies them. +/// Selecting "Custom" therefore RESETS the attribute sliders and skill +/// picks to the Adventurer row's floor spread rather than leaving the +/// current values untouched — retail's own Custom-button handler +/// (gmCGProfessionPage::ListenToElementMessage case 0xed) calls +/// SetTemplate(state, 0, 1) then gmCGProfessionPage::UpdateToDefaultAttributes +/// @ 0x00482860 to refresh the slider UI to match. ACE's +/// PlayerFactory.CreatePlayer confirms the same indexing server-side: +/// it indexes heritageGroup.Templates[characterCreateInfo.TemplateOption] +/// with no special-cased "no template" branch +/// (references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:135-138). +/// Retail schema: Template_CG::Serialize @ 0x005C0450 (ACE's +/// TemplateCG.Unpack mirrors the same field order). +/// +public sealed record ChargenTemplate( + string Name, + uint IconId, + uint TitleStringId, + ChargenAttributeValues Attributes, + IReadOnlyList NormalSkills, + IReadOnlyList PrimarySkills); diff --git a/src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs b/src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs new file mode 100644 index 00000000..788dfe8f --- /dev/null +++ b/src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs @@ -0,0 +1,124 @@ +using System; +using System.Numerics; +using DatReaderWriter.DBObjs; + +namespace AcDream.Core.Physics; + +/// +/// Retail's simplest animation-clip playback shape: advance a frame position +/// at a fixed framerate and wrap it back into [LowFrame, HighFrame], +/// then linearly interpolate one part's origin/orientation between the two +/// bracketing frames. This is the effect of +/// CPhysicsObj::set_sequence_animation (0x0050F6F0) when called +/// with a constant DID and a nonzero framerate and no further motion-command +/// traffic — e.g. gmCG3DView::StartAnimation (0x004EE600), +/// which plays the chargen preview's idle DID at a flat 30 fps with no +/// transitional blending. +/// +/// +/// This exact advance-with-wrap-then-lerp/slerp algorithm already exists as +/// an inline, App-layer-only implementation for the "legacy" (no +/// ) NPC idle-cycle path — +/// LiveEntityAnimationPresenter.Present's non-sequencer branch +/// (CurrFrame += legacyAdvanceSeconds * Framerate with the same +/// modulo wrap) and its private TryResolvePartFrame helper (the same +/// frame-bracket lerp/slerp). That call site has a live entity, a +/// LiveEntityRuntime membership, and per-tick elapsed time supplied by +/// the render loop; the chargen preview has none of that (there is no live +/// entity — character creation hasn't happened yet), so it cannot reuse that +/// class directly. Rather than re-typing the same formula a second time, +/// this Core, pure, unit-testable class is the shared primitive: the +/// chargen preview (AcDream.App.Rendering.ChargenPreviewAnimator) +/// consumes it directly, and it is safe for a future pass to redirect +/// LiveEntityAnimationPresenter's inline copy through it as a +/// behavior-preserving mechanical follow-up (not done here — that file is +/// live, heavily tested production entity-rendering code with zero relation +/// to this preview-only feature, so touching it is out of this slice's +/// blast radius by design, not oversight). Tracked as +/// docs/ISSUES.md #403 so the follow-up has an owner. +/// +/// +public static class RetailAnimationCyclePlayback +{ + /// + /// Advances by elapsedSeconds * framerate + /// and wraps it back into [lowFrame, highFrame] with the SAME modulo + /// shape LiveEntityAnimationPresenter.Present's legacy branch uses + /// (over % (span + 1), not a plain clamp — a frame position that + /// overshoots the end by more than one span wraps around more than once + /// rather than sticking at the boundary, matching a long stall/resume). + /// Returns unchanged for a degenerate cycle + /// ( <= ), a + /// non-positive , or a non-positive + /// . + /// + public static float Advance( + float currFrame, + int lowFrame, + int highFrame, + float framerate, + float elapsedSeconds) + { + int span = highFrame - lowFrame; + if (span <= 0 || framerate <= 0f || elapsedSeconds <= 0f) + return currFrame; + + float next = currFrame + elapsedSeconds * framerate; + if (next > highFrame) + { + float over = next - lowFrame; + next = lowFrame + (over % (span + 1)); + } + else if (next < lowFrame) + { + next = lowFrame; + } + return next; + } + + /// + /// Resolves part 's origin/orientation at + /// by linearly interpolating (lerp origin, + /// slerp orientation) between the frame at floor(currFrame) and + /// the next frame in the cycle (wrapping +1 + /// back to ). Returns false — with + /// default outputs — when is outside + /// the bracketing frame's part list, matching + /// LiveEntityAnimationPresenter.TryResolvePartFrame's no- + /// sequence-frames branch exactly. + /// + public static bool TryInterpolatePart( + Animation animation, + float currFrame, + int lowFrame, + int highFrame, + int partIndex, + out Vector3 origin, + out Quaternion orientation) + { + ArgumentNullException.ThrowIfNull(animation); + + int frameIndex = (int)MathF.Floor(currFrame); + if (frameIndex < lowFrame || frameIndex > highFrame || frameIndex >= animation.PartFrames.Count) + frameIndex = lowFrame; + int nextIndex = frameIndex + 1; + if (nextIndex > highFrame || nextIndex >= animation.PartFrames.Count) + nextIndex = lowFrame; + float t = Math.Clamp(currFrame - frameIndex, 0f, 1f); + + var frames = animation.PartFrames[frameIndex].Frames; + var nextFrames = animation.PartFrames[nextIndex].Frames; + if (partIndex < frames.Count) + { + var first = frames[partIndex]; + var next = partIndex < nextFrames.Count ? nextFrames[partIndex] : first; + origin = Vector3.Lerp(first.Origin, next.Origin, t); + orientation = Quaternion.Slerp(first.Orientation, next.Orientation, t); + return true; + } + + origin = default; + orientation = default; + return false; + } +} diff --git a/src/AcDream.Core/Plugins/LoadedPlugin.cs b/src/AcDream.Core/Plugins/LoadedPlugin.cs index a3f6d249..9f1f534a 100644 --- a/src/AcDream.Core/Plugins/LoadedPlugin.cs +++ b/src/AcDream.Core/Plugins/LoadedPlugin.cs @@ -7,8 +7,10 @@ namespace AcDream.Core.Plugins; /// Outcome of a plugin load attempt. /// On success, is the instantiated plugin, /// owns its assembly, and is null. -/// On failure, and are null and -/// describes what went wrong. +/// On failure, describes what went wrong. A partial +/// and/or may still be present; +/// the caller owns their cleanup. The loader never requests collectible unload +/// itself because the session must first roll back host registrations. /// public sealed record LoadedPlugin( PluginManifest Manifest, @@ -16,5 +18,6 @@ public sealed record LoadedPlugin( AssemblyLoadContext? LoadContext, Exception? Error) { - public bool Success => Plugin is not null && Error is null; + public bool Success => + Plugin is not null && LoadContext is not null && Error is null; } diff --git a/src/AcDream.Core/Plugins/PluginLoader.cs b/src/AcDream.Core/Plugins/PluginLoader.cs index ba2ba07d..1d729f2a 100644 --- a/src/AcDream.Core/Plugins/PluginLoader.cs +++ b/src/AcDream.Core/Plugins/PluginLoader.cs @@ -11,9 +11,15 @@ public static class PluginLoader /// implementing , instantiate it, and call its /// with the supplied host. Any failure /// is returned as a failed rather than thrown. + /// A returned partial plugin/context remains caller-owned; this method never + /// requests unload because the caller must close host registrations first. /// public static LoadedPlugin Load(string pluginDirectory, PluginManifest manifest, IPluginHost host) { + ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory); + ArgumentNullException.ThrowIfNull(manifest); + ArgumentNullException.ThrowIfNull(host); + var dllPath = Path.Combine(pluginDirectory, manifest.EntryDll); if (!File.Exists(dllPath)) return new LoadedPlugin( @@ -22,9 +28,11 @@ public static class PluginLoader LoadContext: null, Error: new FileNotFoundException($"entry dll not found: {dllPath}", dllPath)); + PluginAssemblyLoadContext? alc = null; + IAcDreamPlugin? instance = null; try { - var alc = new PluginAssemblyLoadContext(pluginDirectory, dllPath); + alc = new PluginAssemblyLoadContext(pluginDirectory, dllPath); var asm = alc.LoadFromAssemblyPath(dllPath); IEnumerable types; @@ -41,20 +49,30 @@ public static class PluginLoader .FirstOrDefault(t => !t.IsAbstract && typeof(IAcDreamPlugin).IsAssignableFrom(t)); if (pluginType is null) + { return new LoadedPlugin( manifest, Plugin: null, - LoadContext: null, + LoadContext: alc, Error: new InvalidOperationException( $"no IAcDreamPlugin implementation found in {manifest.EntryDll}")); + } - var instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!; + instance = (IAcDreamPlugin)Activator.CreateInstance(pluginType)!; instance.Initialize(host); return new LoadedPlugin(manifest, instance, alc, Error: null); } catch (Exception ex) { - return new LoadedPlugin(manifest, Plugin: null, LoadContext: null, Error: ex); + // The caller owns rollback for a partial instance/context. In + // particular, Initialize may already have attached host callbacks; + // the per-plugin host scope must remove those registrations before + // Disable or any collectible unload request can run. + return new LoadedPlugin( + manifest, + Plugin: instance, + LoadContext: alc, + Error: ex); } } } diff --git a/src/AcDream.Core/Plugins/PluginSession.cs b/src/AcDream.Core/Plugins/PluginSession.cs new file mode 100644 index 00000000..1436fb6b --- /dev/null +++ b/src/AcDream.Core/Plugins/PluginSession.cs @@ -0,0 +1,421 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Plugins; + +public enum PluginSessionStatusKind +{ + Loaded, + Failed, +} + +/// +/// Final startup outcome for one configured plugin id. Hosts translate these +/// outcomes into their own diagnostics and the Campaign LA status stream. +/// +public readonly record struct PluginSessionStatus( + string Plugin, + PluginSessionStatusKind Kind, + string? Error = null); + +/// +/// One host/session-scoped plugin lifetime. Discovery, allow-listing, +/// initialize/enable, failure isolation, reverse-order disable, and collectible +/// load-context release are shared by graphical and no-window hosts so their +/// configured plugin-set semantics cannot drift. +/// +public sealed class PluginSession : IDisposable +{ + private readonly IPluginHost _host; + private readonly Action? _report; + private readonly List _loaded = []; + private readonly List _releasedContexts = []; + private bool _started; + private bool _disposed; + + public PluginSession( + IPluginHost host, + Action? report = null) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _report = report; + } + + public int LoadedCount => _loaded.Count; + + public IReadOnlyList LoadedPluginIds => + _loaded.Select(static active => active.Loaded.Manifest.Id).ToArray(); + + /// + /// Discovers and starts the configured set exactly once. A + /// allow-list loads every discovered id; an explicit + /// empty list loads none. Matching and duplicate-id handling are + /// ordinal-ignore-case on every operating system because plugin ids are + /// logical identifiers, not paths. + /// + public void Start( + IEnumerable pluginRoots, + IReadOnlyList? allowList) + { + ArgumentNullException.ThrowIfNull(pluginRoots); + ObjectDisposedException.ThrowIf(_disposed, this); + if (_started) + throw new InvalidOperationException("The plugin session has already started."); + _started = true; + + string[] roots = DistinctRoots(pluginRoots); + string[]? requested = allowList is null + ? null + : allowList + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (requested is { Length: 0 }) + return; + + var candidates = new Dictionary>( + StringComparer.OrdinalIgnoreCase); + var errors = new Dictionary>( + StringComparer.OrdinalIgnoreCase); + var discoveredOrder = new List(); + HashSet? requestedSet = requested is null + ? null + : new HashSet(requested, StringComparer.OrdinalIgnoreCase); + + foreach (string root in roots) + { + IReadOnlyList results; + try + { + results = PluginDiscovery.Scan(root); + } + catch (Exception error) when (IsDiscoveryFailure(error)) + { + SafeLog( + static (log, message, exception) => + log.Error(message, exception), + $"plugin discovery failed for root '{root}'", + error); + continue; + } + + foreach (PluginDiscoveryResult result in results) + { + if (!result.Success) + { + string directoryId = Path.GetFileName( + Path.TrimEndingDirectorySeparator(result.PluginDirectory)); + if (string.IsNullOrWhiteSpace(directoryId) + || (requestedSet is not null + && !requestedSet.Contains(directoryId))) + { + continue; + } + + AddOrdered(discoveredOrder, directoryId); + AddError( + errors, + directoryId, + result.Error ?? new InvalidOperationException( + "plugin discovery failed")); + continue; + } + + string id = result.Manifest!.Id; + if (requestedSet is not null && !requestedSet.Contains(id)) + continue; + AddOrdered(discoveredOrder, id); + if (!candidates.TryGetValue(id, out List? list)) + { + list = []; + candidates.Add(id, list); + } + list.Add(result); + } + } + + IEnumerable loadOrder = requested is null + ? discoveredOrder + : requested; + foreach (string id in loadOrder) + LoadOne(id, candidates, errors); + } + + /// + /// Test/diagnostic observation of the exact collectible contexts currently + /// owned by this session. The returned weak references do not delay unload. + /// + public IReadOnlyList CaptureLoadContextWeakReferences() => + [ + .. _releasedContexts, + .. _loaded.Select(static active => + new WeakReference(active.Loaded.LoadContext!)), + ]; + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + for (int index = _loaded.Count - 1; index >= 0; index--) + { + ActivePlugin active = _loaded[index]; + LoadedPlugin loaded = active.Loaded; + try + { + loaded.Plugin!.Disable(); + } + catch (Exception error) + { + SafeLog( + static (log, message, exception) => + log.Error(message, exception), + $"plugin disable failed: {loaded.Manifest.Id}", + error); + } + + // Host-owned registrations are released even when Disable throws. + // This must precede ALC unload so no UI binding or event delegate + // can keep the plugin assembly reachable. + active.Scope.Dispose(); + + try + { + loaded.LoadContext!.Unload(); + } + catch (Exception error) + { + SafeLog( + static (log, message, exception) => + log.Error(message, exception), + $"plugin unload failed: {loaded.Manifest.Id}", + error); + } + } + + // Drop both plugin instances and AssemblyLoadContext references. The + // CLR completes collectible unload after no plugin-owned object remains + // reachable and a normal GC cycle observes the contexts. + _loaded.Clear(); + } + + private void LoadOne( + string id, + IReadOnlyDictionary> candidates, + Dictionary> errors) + { + if (candidates.TryGetValue(id, out List? available)) + { + foreach (PluginDiscoveryResult candidate in available) + { + var scope = new ScopedPluginHost(_host); + LoadedPlugin loaded = PluginLoader.Load( + candidate.PluginDirectory, + candidate.Manifest!, + scope); + if (!loaded.Success) + { + // Initialize can register callbacks before it fails. The + // registration transaction closes before plugin cleanup + // and, critically, before any ALC Unloading notification. + scope.Dispose(); + ReleaseFailedLoad(loaded); + AddError( + errors, + id, + loaded.Error ?? new InvalidOperationException( + "plugin load failed")); + continue; + } + + try + { + loaded.Plugin!.Enable(); + _loaded.Add(new ActivePlugin(loaded, scope)); + SafeLog( + static (log, message, _) => log.Info(message), + $"plugin loaded: {loaded.Manifest.Id} " + + $"({loaded.Manifest.DisplayName})", + null); + Report(new PluginSessionStatus( + loaded.Manifest.Id, + PluginSessionStatusKind.Loaded)); + return; + } + catch (Exception error) + { + AddError(errors, id, error); + ReleaseFailedEnable(loaded, scope); + } + } + } + + if (!errors.TryGetValue(id, out List? failures) + || failures.Count == 0) + { + failures = + [ + new FileNotFoundException( + $"plugin '{id}' was not found in the configured plugin roots."), + ]; + } + + string errorText = string.Join( + " | ", + failures.Select(Describe)); + Report(new PluginSessionStatus( + id, + PluginSessionStatusKind.Failed, + errorText)); + SafeLog( + static (log, message, _) => log.Warn(message), + $"plugin failed: {id}: {errorText}", + null); + } + + private void ReleaseFailedEnable( + LoadedPlugin loaded, + ScopedPluginHost scope) + { + try + { + loaded.Plugin!.Disable(); + } + catch (Exception error) + { + SafeLog( + static (log, message, exception) => + log.Error(message, exception), + $"plugin cleanup after enable failure failed: {loaded.Manifest.Id}", + error); + } + + scope.Dispose(); + + _releasedContexts.Add(new WeakReference(loaded.LoadContext!)); + try + { + loaded.LoadContext!.Unload(); + } + catch (Exception error) + { + SafeLog( + static (log, message, exception) => + log.Error(message, exception), + $"plugin unload after enable failure failed: {loaded.Manifest.Id}", + error); + } + } + + private void ReleaseFailedLoad(LoadedPlugin loaded) + { + if (loaded.Plugin is not null) + { + try + { + loaded.Plugin.Disable(); + } + catch (Exception error) + { + SafeLog( + static (log, message, exception) => + log.Error(message, exception), + $"plugin cleanup after initialize failure failed: {loaded.Manifest.Id}", + error); + } + } + + if (loaded.LoadContext is null) + return; + + _releasedContexts.Add(new WeakReference(loaded.LoadContext)); + try + { + loaded.LoadContext.Unload(); + } + catch (Exception error) + { + SafeLog( + static (log, message, exception) => + log.Error(message, exception), + $"plugin unload after load failure failed: {loaded.Manifest.Id}", + error); + } + } + + private void Report(PluginSessionStatus status) + { + if (_report is null) + return; + try + { + _report(status); + } + catch (Exception error) + { + SafeLog( + static (log, message, exception) => + log.Error(message, exception), + $"plugin status observer failed for {status.Plugin}", + error); + } + } + + private void SafeLog( + Action write, + string message, + Exception? error) + { + try { write(_host.Log, message, error); } + catch { } + } + + private static string[] DistinctRoots(IEnumerable roots) + { + StringComparer comparer = OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + return roots + .Where(static root => !string.IsNullOrWhiteSpace(root)) + .Select(Path.GetFullPath) + .Distinct(comparer) + .ToArray(); + } + + private static void AddOrdered(List ordered, string id) + { + if (!ordered.Contains(id, StringComparer.OrdinalIgnoreCase)) + ordered.Add(id); + } + + private static void AddError( + Dictionary> errors, + string id, + Exception error) + { + if (!errors.TryGetValue(id, out List? list)) + { + list = []; + errors.Add(id, list); + } + list.Add(error); + } + + private static string Describe(Exception error) + { + Exception root = error.GetBaseException(); + return string.IsNullOrWhiteSpace(root.Message) + ? root.GetType().Name + : root.Message; + } + + private static bool IsDiscoveryFailure(Exception error) => + error is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException + or System.Security.SecurityException; + + private sealed record ActivePlugin( + LoadedPlugin Loaded, + ScopedPluginHost Scope); +} diff --git a/src/AcDream.Core/Plugins/ScopedPluginHost.cs b/src/AcDream.Core/Plugins/ScopedPluginHost.cs new file mode 100644 index 00000000..ee1667f1 --- /dev/null +++ b/src/AcDream.Core/Plugins/ScopedPluginHost.cs @@ -0,0 +1,272 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Plugins; + +/// +/// Per-plugin host view that owns every registration made through the public +/// event/selection/UI surfaces. Disposal is the host's rollback boundary: it removes +/// registrations even when plugin Initialize/Enable/Disable code throws. +/// +internal sealed class ScopedPluginHost : IPluginHost, IDisposable +{ + private readonly IPluginHost _inner; + private readonly ScopedEvents _events; + private readonly ScopedSelectionService _selection; + private readonly ScopedUiRegistry _ui; + private bool _disposed; + + internal ScopedPluginHost(IPluginHost inner) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + _events = new ScopedEvents(inner.Events); + _selection = new ScopedSelectionService(inner.Selection); + _ui = new ScopedUiRegistry(inner.Ui); + } + + public bool HasUi => _inner.HasUi; + public IPluginLogger Log => _inner.Log; + public IGameState State => _inner.State; + public IEvents Events => _events; + public ISelectionService Selection => _selection; + public IUiRegistry Ui => _ui; + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _events.Dispose(); + _selection.Dispose(); + _ui.Dispose(); + } + + private sealed class ScopedSelectionService(ISelectionService inner) + : ISelectionService, + IDisposable + { + private readonly object _gate = new(); + private readonly List> _registrations = []; + private bool _disposed; + + public uint? SelectedObjectId => inner.SelectedObjectId; + public uint? PreviousObjectId => inner.PreviousObjectId; + + public event Action Changed + { + add + { + ArgumentNullException.ThrowIfNull(value); + try + { + inner.Changed += value; + } + catch + { + try { inner.Changed -= value; } + catch { } + throw; + } + lock (_gate) + { + if (!_disposed) + { + _registrations.Add(value); + return; + } + } + + try { inner.Changed -= value; } + catch { } + throw new ObjectDisposedException(nameof(ScopedSelectionService)); + } + remove + { + if (value is null) + return; + inner.Changed -= value; + lock (_gate) + RemoveLast(value); + } + } + + public bool Select(uint objectId) + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return inner.Select(objectId); + } + } + + public bool Clear() + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return inner.Clear(); + } + } + + public void Dispose() + { + Action[] registrations; + lock (_gate) + { + if (_disposed) + return; + _disposed = true; + registrations = _registrations.ToArray(); + _registrations.Clear(); + } + + for (int index = registrations.Length - 1; index >= 0; index--) + { + try { inner.Changed -= registrations[index]; } + catch { } + } + } + + private void RemoveLast(Action handler) + { + for (int index = _registrations.Count - 1; index >= 0; index--) + { + if (_registrations[index] != handler) + continue; + _registrations.RemoveAt(index); + return; + } + } + } + + private sealed class ScopedEvents(IEvents inner) : IEvents, IDisposable + { + private readonly object _gate = new(); + private readonly List> _registrations = []; + private bool _disposed; + + public event Action EntitySpawned + { + add + { + ArgumentNullException.ThrowIfNull(value); + try + { + inner.EntitySpawned += value; + } + catch + { + // A custom event source may mutate before its add accessor + // faults. Best-effort removal keeps the scope transactional. + try { inner.EntitySpawned -= value; } + catch { } + throw; + } + lock (_gate) + { + if (!_disposed) + { + _registrations.Add(value); + return; + } + } + + // Disposal may race the host subscription call. In that case + // the disposal snapshot could not see this registration, so + // the attaching thread must roll it back before returning. + try { inner.EntitySpawned -= value; } + catch { } + throw new ObjectDisposedException(nameof(ScopedEvents)); + } + remove + { + if (value is null) + return; + inner.EntitySpawned -= value; + lock (_gate) + RemoveLast(value); + } + } + + public void Dispose() + { + Action[] registrations; + lock (_gate) + { + if (_disposed) + return; + _disposed = true; + registrations = _registrations.ToArray(); + _registrations.Clear(); + } + + for (int index = registrations.Length - 1; index >= 0; index--) + { + try { inner.EntitySpawned -= registrations[index]; } + catch { } + } + } + + private void RemoveLast(Action handler) + { + for (int index = _registrations.Count - 1; index >= 0; index--) + { + if (_registrations[index] != handler) + continue; + _registrations.RemoveAt(index); + return; + } + } + } + + private sealed class ScopedUiRegistry : IUiRegistry, IDisposable + { + private readonly IScopedUiRegistry _inner; + private readonly object _gate = new(); + private readonly List _registrations = []; + private bool _disposed; + + internal ScopedUiRegistry(IUiRegistry inner) + { + _inner = inner as IScopedUiRegistry + ?? throw new InvalidOperationException( + "Plugin hosts must expose an IScopedUiRegistry so UI registrations can be rolled back."); + } + + public void AddMarkupPanel(string markupPath, object binding) + { + IDisposable registration = _inner.RegisterMarkupPanel( + markupPath, + binding); + lock (_gate) + { + if (!_disposed) + { + _registrations.Add(registration); + return; + } + } + + registration.Dispose(); + throw new ObjectDisposedException(nameof(ScopedUiRegistry)); + } + + public void Dispose() + { + IDisposable[] registrations; + lock (_gate) + { + if (_disposed) + return; + _disposed = true; + registrations = _registrations.ToArray(); + _registrations.Clear(); + } + + for (int index = registrations.Length - 1; index >= 0; index--) + { + try { registrations[index].Dispose(); } + catch { } + } + } + } +} diff --git a/src/AcDream.Core/Textures/SurfaceDecoder.cs b/src/AcDream.Core/Textures/SurfaceDecoder.cs index 6cbd108f..33a1fefb 100644 --- a/src/AcDream.Core/Textures/SurfaceDecoder.cs +++ b/src/AcDream.Core/Textures/SurfaceDecoder.cs @@ -1,8 +1,10 @@ +using System.Collections.Concurrent; using AcDream.Core.Rendering.Wb; using BCnEncoder.Decoder; using BCnEncoder.Shared; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; +using StbImageSharp; namespace AcDream.Core.Textures; @@ -10,6 +12,34 @@ public static class SurfaceDecoder { private static readonly BcDecoder BcDecoder = new(); + /// + /// Campaign LA gate round 2 (character-select screen): a real, DAT-resolved, + /// non-zero-id RenderSurface can still hit the magenta fallback below (unsupported + /// PixelFormat, a paletted format with no palette, or corrupt/undersized + /// SourceData). That is a DIFFERENT trap than the zero-id footgun documented in + /// claude-memory/feedback_ui_resolve_zero_magenta.md ("guard on the id, not + /// the handle") — this one has a real id and a real handle, so that guard cannot + /// catch it. Both traps produce the identical silent 1x1 magenta texture, so this + /// one needs the same "loud, not silent" treatment: log once per surface id so an + /// undecodable asset fails LOUD in diagnostics instead of shipping as a silent + /// magenta wash (this is exactly how LA8's character-select background, + /// RenderSurface 0x06007576/PFID_CUSTOM_RAW_JPEG, went unnoticed — nothing logged + /// when its decode fell through to the unsupported-format arm). + /// + private static readonly ConcurrentDictionary LoggedMagentaIds = new(); + + private static DecodedTexture LogMagentaOnce(RenderSurface rs, string reason) + { + if (LoggedMagentaIds.TryAdd(rs.Id, 0)) + { + Console.WriteLine( + $"[UI] SurfaceDecoder: RenderSurface 0x{rs.Id:X8} decoded to the 1x1 " + + $"magenta placeholder ({reason}; format={rs.Format} " + + $"{rs.Width}x{rs.Height})."); + } + return DecodedTexture.Magenta; + } + /// /// Decode a RenderSurface's pixel bytes into RGBA8. Returns /// for unsupported formats, null data, or corrupt sizing. This overload does NOT @@ -31,8 +61,35 @@ public static class SurfaceDecoder /// public static DecodedTexture DecodeRenderSurface(RenderSurface rs, Palette? palette, bool isClipMap = false, bool isAdditive = false) { - if (rs.SourceData is null || rs.Width <= 0 || rs.Height <= 0) - return DecodedTexture.Magenta; + if (rs.SourceData is null) + return LogMagentaOnce(rs, "null SourceData"); + + // PFID_CUSTOM_RAW_JPEG carries a complete JFIF-encoded image verbatim in + // SourceData. Retail's RenderSurface::CreateFromSourceData (named-retail + // decomp @0x004440a0) hands this exact byte stream to the Intel JPEG Library + // (`_ijlInit`/`_ijlRead`/`_ijlFree`) at RUNTIME, and the real pixel dimensions + // come from the JPEG's own SOF header — NOT from this RenderSurface's + // Width/Height fields, which are legitimately 0 on disk for this format + // (confirmed against the installed DAT: 0x06007576, the LA8 character-select + // screen's root background, carries Width=0/Height=0 with a 414,230-byte + // FFD8...FFD9 JFIF stream that decodes to 800x600 — exactly the screen's + // LayoutDesc-authored size). Handle it before the generic Width/Height guard + // below, which does not apply to this format and previously made every + // PFID_CUSTOM_RAW_JPEG surface fall straight to magenta. + if (rs.Format == PixelFormat.PFID_CUSTOM_RAW_JPEG) + { + try + { + return DecodeCustomRawJpeg(rs); + } + catch (Exception ex) + { + return LogMagentaOnce(rs, $"JPEG decode failed: {ex.Message}"); + } + } + + if (rs.Width <= 0 || rs.Height <= 0) + return LogMagentaOnce(rs, "non-positive Width/Height"); try { @@ -46,18 +103,40 @@ public static class SurfaceDecoder PixelFormat.PFID_DXT5 => DecodeBc(rs, CompressionFormat.Bc3, isClipMap), PixelFormat.PFID_A8 or PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA => DecodeA8(rs, isAdditive), PixelFormat.PFID_P8 when palette is not null => DecodeP8(rs, palette, isClipMap), + PixelFormat.PFID_P8 => LogMagentaOnce(rs, "PFID_P8 with no palette"), PixelFormat.PFID_INDEX16 when palette is not null => DecodeIndex16(rs, palette, isClipMap), + PixelFormat.PFID_INDEX16 => LogMagentaOnce(rs, "PFID_INDEX16 with no palette"), PixelFormat.PFID_R5G6B5 => DecodeR5G6B5(rs), PixelFormat.PFID_A4R4G4B4 => DecodeA4R4G4B4(rs), - _ => DecodedTexture.Magenta, + _ => LogMagentaOnce(rs, $"unsupported PixelFormat {rs.Format}"), }; } - catch + catch (Exception ex) { - return DecodedTexture.Magenta; + return LogMagentaOnce(rs, $"decode threw: {ex.Message}"); } } + /// + /// Decode PFID_CUSTOM_RAW_JPEG: see the doc comment on the + /// branch in + /// for the + /// retail mechanism this replaces. JPEG is a standardized (ITU T.81) format, so any + /// conforming decoder reproduces the same pixels the Intel JPEG Library would. + /// StbImageSharp (dual Unlicense/MIT, pure managed, no native dependency) is + /// acdream's decoder so the same code path works on the Linux headless/graphical + /// targets Slice K/L commit to. Throws on any failure; the caller converts that + /// into the logged magenta placeholder — this method never returns Magenta itself. + /// + private static DecodedTexture DecodeCustomRawJpeg(RenderSurface rs) + { + ImageResult image = ImageResult.FromMemory(rs.SourceData!, ColorComponents.RedGreenBlueAlpha); + if (image.Width <= 0 || image.Height <= 0) + throw new InvalidDataException( + $"JPEG surface 0x{rs.Id:X8} decoded to {image.Width}x{image.Height}."); + return new DecodedTexture(image.Data, image.Width, image.Height); + } + private static DecodedTexture DecodeIndex16(RenderSurface rs, Palette palette, bool isClipMap) { int expectedBytes = rs.Width * rs.Height * 2; diff --git a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs index 37e62a4d..6bf5f0e1 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs @@ -46,11 +46,34 @@ internal sealed record HeadlessSessionDescriptor [JsonRequired] public string Account { get; init; } = string.Empty; - [JsonRequired] - public HeadlessCharacterSelector Character { get; init; } = new(); + /// + /// Campaign LA slice LA2: the JSON field is ABSENT for normal play + /// sessions (explicit JSON null is invalid); + /// for the LA2 probe + /// (connect → characterList → graceful disconnect, never EnterWorld) — + /// the pinned launch-contract schema's mode field + /// (docs/plans/2026-08-14-launcher-campaign.md LA1/LA2). + /// / requiredness depends on + /// this value, which is why their requiredness lives in + /// 's semantic validation rather + /// than a [JsonRequired] attribute — that attribute fires during + /// deserialization, before can be inspected at all. + /// + public HeadlessSessionMode? Mode { get; init; } - [JsonRequired] - public HeadlessBotPolicyDescriptor Policy { get; init; } = new(); + /// + /// Required for play sessions ( absent); MUST be + /// omitted for probe sessions () — + /// the pinned contract keeps the shape unambiguous by forbidding a probe + /// session from also declaring a selector. Enforced by + /// , not + /// [JsonRequired] (see this record's own doc on ). + /// + public HeadlessCharacterSelector? Character { get; init; } + + /// Same mode-dependent requiredness as : + /// required for play sessions, forbidden for probe sessions. + public HeadlessBotPolicyDescriptor? Policy { get; init; } [JsonRequired] public HeadlessCredentialReference Credential { get; init; } = new(); @@ -72,6 +95,36 @@ internal sealed record HeadlessSessionDescriptor /// legal no-ops. /// public Dictionary? CharacterOptions { get; init; } + + /// + /// Campaign LA slice LA1: plugin ids to load from the standard plugins + /// directory (docs/plans/2026-08-14-launcher-campaign.md LA1). + /// Absent means load every discovered plugin (the developer flow); + /// explicit empty means load none. LA5 host composition consumes this + /// as the actual allow-list filter. + /// + public List? Plugins { get; init; } + + /// + /// Campaign LA slice LA1/LA6: ordered chat-typed strings run through the + /// shared Runtime parser/router once the session enters world. + /// + public List? LoginCommands { get; init; } + + /// + /// Campaign LA slice LA1: inter-command delay for + /// , in milliseconds. Matches the pinned + /// launch-contract default (500 ms) when the field is absent from the + /// document. + /// + public int LoginCommandDelayMs { get; init; } = 500; + + /// + /// Campaign LA slice LA1: absolute path for this session's status-event + /// JSONL stream (docs/superpowers/specs/2026-08-14-launcher-campaign-design.md + /// §6). Absent selects the writer's permanent no-op mode. + /// + public string? StatusFile { get; init; } } internal sealed class HeadlessEndpointDescriptor @@ -108,6 +161,21 @@ internal sealed class HeadlessBotPolicyDescriptor public HeadlessBotPolicyRole? Role { get; init; } } +/// +/// Campaign LA slice LA2: see . +/// The pinned launch-contract schema defines exactly two states for a +/// session — ABSENT (mapped to , meaning "play") or +/// the literal string "probe" — so is the only +/// member; there is no explicit "play" spelling. This deliberately uses +/// 's global camel-case, +/// string-only enum converter; a per-enum converter with its default options +/// would accidentally accept numeric 0 as a second probe spelling. +/// +internal enum HeadlessSessionMode +{ + Probe, +} + /// See . [JsonConverter(typeof(JsonStringEnumConverter))] internal enum HeadlessBotPolicyRole diff --git a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs index 101850c8..389b2a6f 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs @@ -97,10 +97,15 @@ internal static class HeadlessConfigurationLoader string fullPath = Path.GetFullPath(path); using FileStream stream = File.OpenRead(fullPath); + using JsonDocument document = JsonDocument.Parse( + stream, + new JsonDocumentOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + }); HeadlessConfiguration? configuration = - JsonSerializer.Deserialize( - stream, - Options); + document.RootElement.Deserialize(Options); if (configuration is null) { @@ -123,9 +128,12 @@ internal static class HeadlessConfigurationLoader ValidateContent(configuration.Process?.Content); + JsonElement sessionsElement = + document.RootElement.GetProperty("sessions"); var sessionIds = new HashSet(StringComparer.Ordinal); var credentialReferences = new HashSet( StringComparer.Ordinal); + int sessionIndex = 0; foreach (HeadlessSessionDescriptor? session in configuration.Sessions) { if (session is null @@ -141,7 +149,7 @@ internal static class HeadlessConfigurationLoader $"Duplicate session id '{session.Id}'."); } - ValidateSession(session); + ValidateSession(session, sessionsElement[sessionIndex]); string credentialKey = $"{session.Credential.Provider}:{session.Credential.Reference}"; if (!credentialReferences.Add(credentialKey)) @@ -149,6 +157,7 @@ internal static class HeadlessConfigurationLoader throw new HeadlessConfigurationException( $"Credential reference for session '{session.Id}' is already in use."); } + sessionIndex++; } return configuration; @@ -166,7 +175,9 @@ internal static class HeadlessConfigurationLoader } } - private static void ValidateSession(HeadlessSessionDescriptor session) + private static void ValidateSession( + HeadlessSessionDescriptor session, + JsonElement sessionElement) { if (session.Endpoint is null || string.IsNullOrWhiteSpace(session.Endpoint.Host) @@ -182,6 +193,83 @@ internal static class HeadlessConfigurationLoader $"Session '{session.Id}' requires a non-empty account."); } + ValidateModeShape(session, sessionElement); + + if (session.Credential is null + || string.IsNullOrWhiteSpace(session.Credential.Reference)) + { + throw new HeadlessConfigurationException( + $"Session '{session.Id}' requires a credential reference."); + } + + ValidateCharacterOptions(session); + ValidateLaunchContractFields(session); + } + + /// + /// Campaign LA slice LA2: mode-dependent requiredness for + /// / + /// — this REPLACES the + /// former `[JsonRequired]` attributes on both properties (which fired + /// unconditionally at deserialize time, before a probe session's + /// omission could ever be distinguished from a play session's mistake). + /// A play session (mode absent) keeps EXACTLY today's strictness: a + /// missing/malformed character selector or a missing policy id still + /// fails load, just via + /// naming the field instead of a raw citing + /// "missing required properties" — same exit code (3, + /// HeadlessExitCode.ConfigurationError) either way, more specific + /// text now (an accepted improvement, not a contract change). A probe + /// session (mode "probe") must OMIT both fields entirely — the pinned + /// contract keeps the shape unambiguous by rejecting a probe session + /// that also declares a selector or a policy, rather than silently + /// ignoring them. + /// + private static void ValidateModeShape( + HeadlessSessionDescriptor session, + JsonElement sessionElement) + { + bool hasMode = sessionElement.TryGetProperty( + "mode", + out JsonElement modeElement); + bool hasCharacter = sessionElement.TryGetProperty( + "character", + out JsonElement characterElement); + bool hasPolicy = sessionElement.TryGetProperty( + "policy", + out JsonElement policyElement); + + RejectExplicitNull(session.Id, "mode", hasMode, modeElement); + RejectExplicitNull( + session.Id, + "character", + hasCharacter, + characterElement); + RejectExplicitNull(session.Id, "policy", hasPolicy, policyElement); + + if (session.Mode == HeadlessSessionMode.Probe) + { + if (hasCharacter) + { + throw new HeadlessConfigurationException( + $"Session '{session.Id}' has mode \"probe\" and must omit " + + "'character' — a probe never selects a character."); + } + if (hasPolicy) + { + throw new HeadlessConfigurationException( + $"Session '{session.Id}' has mode \"probe\" and must omit " + + "'policy' — a probe never drives a bot policy."); + } + return; + } + + if (hasMode) + { + throw new HeadlessConfigurationException( + $"Session '{session.Id}' is normal play and must omit 'mode'."); + } + if (session.Character is null) { throw new HeadlessConfigurationException( @@ -206,15 +294,65 @@ internal static class HeadlessConfigurationLoader throw new HeadlessConfigurationException( $"Session '{session.Id}' requires a non-empty policy id."); } + } - if (session.Credential is null - || string.IsNullOrWhiteSpace(session.Credential.Reference)) + /// + /// Campaign LA LA2 review fix: the pinned launch contract distinguishes + /// an omitted conditional field from a field explicitly authored as JSON + /// null. Nullable CLR properties cannot retain that distinction, so + /// validation also consumes the already-parsed strict JSON shape. The + /// typed serializer still owns unknown-member, enum, and value-type + /// enforcement; this check adds presence semantics without weakening any + /// of those gates. + /// + private static void RejectExplicitNull( + string sessionId, + string propertyName, + bool isPresent, + JsonElement value) + { + if (isPresent && value.ValueKind == JsonValueKind.Null) { throw new HeadlessConfigurationException( - $"Session '{session.Id}' requires a credential reference."); + $"Session '{sessionId}' field '{propertyName}' cannot be null; " + + "supply a value when allowed or omit the field."); + } + } + + /// + /// Campaign LA slice LA1: validates the four new optional per-session + /// fields shared with the App session-config reader (see + /// docs/plans/2026-08-14-launcher-campaign.md LA1's pinned + /// contract). All four stay optional; this loader owns their strict + /// shape checks while LA5/LA6 host composition consumes the resulting + /// plugin allow-list and ordered login-command sequence. + /// + private static void ValidateLaunchContractFields(HeadlessSessionDescriptor session) + { + if (session.Plugins is { } plugins) + { + foreach (string? plugin in plugins) + { + if (string.IsNullOrWhiteSpace(plugin)) + { + throw new HeadlessConfigurationException( + $"Session '{session.Id}' plugins entries must be non-empty strings."); + } + } } - ValidateCharacterOptions(session); + if (session.LoginCommandDelayMs < 0) + { + throw new HeadlessConfigurationException( + $"Session '{session.Id}' loginCommandDelayMs must be non-negative."); + } + + if (session.StatusFile is not null + && string.IsNullOrWhiteSpace(session.StatusFile)) + { + throw new HeadlessConfigurationException( + $"Session '{session.Id}' statusFile must be a non-empty path when present."); + } } /// diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs index 4d4d1b83..c620c52a 100644 --- a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs @@ -56,6 +56,11 @@ internal sealed class HeadlessProcessHost : IDisposable paths.ConfigDirectory); var sessions = new List( configuration.Sessions.Count); + string[] pluginRoots = + [ + Path.Combine(AppContext.BaseDirectory, "plugins"), + paths.PluginsDirectory, + ]; HeadlessProcessContentOwner? content = null; HeadlessProcessResourceSampler? resources = null; // FA6: constructed unconditionally — cheap, and every non-gate @@ -104,7 +109,8 @@ internal sealed class HeadlessProcessHost : IDisposable sessionOperations, timeProvider, contentLease: contentLease, - gateCoordinator: gateCoordinator)); + gateCoordinator: gateCoordinator, + pluginRoots: pluginRoots)); } catch { @@ -200,6 +206,22 @@ internal sealed class HeadlessProcessHost : IDisposable foreach (HeadlessSessionHost session in _sessions) { RuntimeSessionStartResult started = session.Start(); + // Campaign LA slice LA2: ProbeComplete is a SUCCESS variant, not + // a connection failure — the session already connected, reported + // its roster, and gracefully disconnected before EnterWorld (see + // LiveSessionController's probe short-circuit). Continue to the + // next configured session instead of returning ConnectionError, + // so a probe session sharing a process with play sessions never + // tears the others down. ProbeHeadlessBotPolicy already reports + // IsComplete, so the scheduler below skips this session entirely. + if (started.Status == RuntimeSessionStartStatus.ProbeComplete) + { + _diagnostics.Lifecycle( + session.SessionId, + "probed", + session.Runtime); + continue; + } if (started.Status != RuntimeSessionStartStatus.Connected) { if (started.Error is { } error) diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 5682e156..2b1ef090 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -1,10 +1,13 @@ using AcDream.Headless.Configuration; using AcDream.Headless.Credentials; using AcDream.Headless.Diagnostics; +using AcDream.Headless.Plugins; using AcDream.Headless.Policies; +using AcDream.Content.CharGen; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Runtime; +using AcDream.Runtime.Chat; using AcDream.Runtime.Gameplay; using AcDream.Runtime.Physics; using AcDream.Runtime.Session; @@ -13,29 +16,54 @@ namespace AcDream.Headless.Hosting; internal sealed class HeadlessSessionHost : IDisposable { - private sealed class SessionCommandRoute( - ILiveSessionCommandRouting gameplay, - ILiveSessionCommandRouting commands) - : ILiveSessionCommandRouting + private sealed class SessionCommandRoute : ILiveSessionCommandRouting { - private bool _gameplayActive; - private bool _commandsActive; + private ILiveSessionCommandRouting? _gameplay; + private ILiveSessionCommandRouting? _commands; + private ILiveSessionCommandRouting? _chat; + private bool _activated; + + internal SessionCommandRoute( + ILiveSessionCommandRouting gameplay, + ILiveSessionCommandRouting commands, + ILiveSessionCommandRouting chat) + { + _gameplay = gameplay + ?? throw new ArgumentNullException(nameof(gameplay)); + _commands = commands + ?? throw new ArgumentNullException(nameof(commands)); + _chat = chat + ?? throw new ArgumentNullException(nameof(chat)); + } public void Activate() { - if (_gameplayActive || _commandsActive) + if (_activated) return; - gameplay.Activate(); - _gameplayActive = true; + if (_gameplay is null || _commands is null || _chat is null) + throw new ObjectDisposedException(nameof(SessionCommandRoute)); + + _gameplay.Activate(); try { - commands.Activate(); - _commandsActive = true; + _commands.Activate(); + _chat.Activate(); + _activated = true; } - catch + catch (Exception activationError) { - gameplay.Dispose(); - _gameplayActive = false; + try + { + Dispose(); + } + catch (Exception disposalError) + { + throw new AggregateException( + "Headless command-route activation and rollback failed.", + activationError, + disposalError); + } + throw; } } @@ -43,30 +71,9 @@ internal sealed class HeadlessSessionHost : IDisposable public void Dispose() { List? failures = null; - if (_commandsActive) - { - try - { - commands.Dispose(); - } - catch (Exception error) - { - (failures ??= []).Add(error); - } - _commandsActive = false; - } - if (_gameplayActive) - { - try - { - gameplay.Dispose(); - } - catch (Exception error) - { - (failures ??= []).Add(error); - } - _gameplayActive = false; - } + TryDispose(ref _chat, ref failures); + TryDispose(ref _commands, ref failures); + TryDispose(ref _gameplay, ref failures); if (failures is not null) { throw new AggregateException( @@ -74,6 +81,24 @@ internal sealed class HeadlessSessionHost : IDisposable failures); } } + + private static void TryDispose( + ref ILiveSessionCommandRouting? route, + ref List? failures) + { + if (route is not { } current) + return; + + try + { + current.Dispose(); + route = null; + } + catch (Exception error) + { + (failures ??= []).Add(error); + } + } } private sealed class SessionCommandBridge : IRuntimeSessionCommands @@ -113,6 +138,28 @@ internal sealed class HeadlessSessionHost : IDisposable private readonly HeadlessCredentialSecret _credential; private readonly HeadlessDiagnosticWriter _diagnostics; /// + /// Campaign LA slice LA1: a SEPARATE per-session sink from + /// — a no-op instance when + /// was not configured. + /// See 's own doc for why this is not a + /// rework of the shared-stdout diagnostics writer. + /// + private readonly SessionStatusWriter _statusWriter; + /// + /// Campaign LA LA2 review fix: the actual result returned by the process + /// start attempt. A configured probe mode is only intent; terminal status + /// may claim reason:"probe" after this records + /// . Any other + /// non-connected result maps to the same connection-error code returned by + /// . + /// + private RuntimeSessionStartStatus? _startOutcome; + /// Guards 's disconnected status event + /// so a Stop() on a session that never actually reached Connected (e.g. + /// disposing a fresh, never-started host) does not report a spurious + /// disconnect. + private bool _hasConnected; + /// /// Campaign OP slice OP7 (2026-08-11), D8: the parsed /// characterOptions block — empty when the config omitted it. /// Parsed once at construction; @@ -133,6 +180,7 @@ internal sealed class HeadlessSessionHost : IDisposable private readonly IDisposable _hostLease; private readonly IHeadlessBotPolicy _policy; private readonly IDisposable _policySubscription; + private readonly HeadlessPluginSession _pluginSession; private readonly LiveSessionHost _liveSession; private readonly RuntimeLocalPlayerFrameController _localPlayerFrame; private readonly HeadlessProcessContentOwner.HeadlessProcessContentLease? @@ -216,7 +264,8 @@ internal sealed class HeadlessSessionHost : IDisposable contentLease = null, IHeadlessBotPolicy? policyOverride = null, IRuntimePlacementProjectionSink? placementSinkOverride = null, - FellowshipAllegianceGateCoordinator? gateCoordinator = null) + FellowshipAllegianceGateCoordinator? gateCoordinator = null, + IEnumerable? pluginRoots = null) { _descriptor = descriptor ?? throw new ArgumentNullException(nameof(descriptor)); @@ -241,6 +290,7 @@ internal sealed class HeadlessSessionHost : IDisposable IDisposable? hostLease = null; IHeadlessBotPolicy? policy = null; IDisposable? policySubscription = null; + HeadlessPluginSession? pluginSession = null; try { var gameplay = new HeadlessGameplayOperations(); @@ -261,6 +311,22 @@ internal sealed class HeadlessSessionHost : IDisposable { runtime.CharacterOwner.InstallSpellMetadata( content.MagicCatalog.SpellTable); + // Review fix round F6 (2026-08-15): mirrors the spell- + // metadata install directly above — without this, a + // content-bearing headless host's ChargenOptions stayed + // ChargenOptions.Empty (LiveSessionController's own + // construction default) forever, so + // RuntimeCharacterCreationState refused every chargen + // command (TrySelectHeritage etc. all validate against + // Options) even though CharacterCreated/CreationFailed were + // already wired below. A content-less host (contentLease is + // null, e.g. a bot that never needs to create a character) + // is still a validated-legal configuration per the R9 note + // near _contentLease's other reads — it simply cannot issue + // chargen commands, matching a content-less host's existing + // inability to resolve spell/collision data either. + runtime.Session.CharacterCreationState.InstallOptions( + ChargenTableReader.Load(content.Dats)); } gameplay.Bind( runtime, @@ -271,6 +337,29 @@ internal sealed class HeadlessSessionHost : IDisposable var commands = new DirectGameRuntimeCommandAdapter( runtime, bridge); + // Campaign LA slice LA1: no-op instance when + // descriptor.StatusFile is unset — every call site below stays + // unconditional. + var statusWriter = new SessionStatusWriter(descriptor.StatusFile); + var chatCommandSurface = new LiveChatCommandSurface(); + var loginCommands = new LoginCommandSequence( + descriptor.LoginCommands, + TimeSpan.FromMilliseconds(descriptor.LoginCommandDelayMs), + new RuntimeChatCommandFeedback(runtime.CommunicationOwner), + chatCommandSurface, + failure => statusWriter.LoginCommandFailed( + descriptor.Id, + failure.CommandIndex, + failure.Command, + failure.Error), + _timeProvider); + pluginSession = HeadlessPluginSession.Create( + runtime, + diagnostics, + statusWriter, + descriptor.Id, + pluginRoots ?? [], + descriptor.Plugins); var liveSession = new LiveSessionHost( runtime.Session, new LiveSessionHostBindings( @@ -278,7 +367,12 @@ internal sealed class HeadlessSessionHost : IDisposable CreateEventRoute, session => new SessionCommandRoute( gameplay.CreateRoute(session), - commands.CreateRoute(session))), + commands.CreateRoute(session), + chatCommandSurface.Attach( + new LiveChatCommandRoute( + CreateChatCommandBindings( + session, + runtime))))), generation => runtime.ResetGeneration(generation, _resetHost), new LiveSessionSelectionBindings( @@ -302,7 +396,7 @@ internal sealed class HeadlessSessionHost : IDisposable // doc). Gating on role keeps two sessions // writing the SAME field from ever racing — // only one role ever writes it. - if (descriptor.Policy.Role + if (descriptor.Policy?.Role == HeadlessBotPolicyRole.Recruit && gateCoordinator is not null) { @@ -318,14 +412,37 @@ internal sealed class HeadlessSessionHost : IDisposable descriptor.Id, $"connecting:{host}:{port}:{user}", runtime.Generation.Value), - () => diagnostics.Message( + () => + { + diagnostics.Message( + descriptor.Id, + "connected", + runtime.Generation.Value); + statusWriter.Connected(descriptor.Id); + _hasConnected = true; + }, + roster => statusWriter.CharacterList(descriptor.Id, roster), + selection => statusWriter.EnteredWorld( descriptor.Id, - "connected", - runtime.Generation.Value))); + selection.CharacterId, + selection.CharacterName), + loginCommands, + // Campaign CC slice CC4: same status-parity wiring as + // the graphical host (LiveSessionRuntimeFactory.Create). + CharacterCreated: identity => statusWriter.CharacterCreated( + descriptor.Id, + identity.Guid, + identity.Name), + CreationFailed: rejection => statusWriter.CreationFailed( + descriptor.Id, + rejection.RawCode, + rejection.Reason, + rejection.AttemptedName))); Runtime = runtime; Commands = commands; _liveSession = liveSession; + _statusWriter = statusWriter; _localPlayerFrame = runtime.CreateLocalPlayerFrameController( new HeadlessLocalPlayerFrameHost( @@ -338,13 +455,24 @@ internal sealed class HeadlessSessionHost : IDisposable hostLease = runtime.AcquireHostLease( $"headless:{descriptor.Id}"); + // Campaign LA slice LA2: a probe session's descriptor carries no + // `policy` at all (the loader rejects the opposite pairing) — a + // probe never reaches TrySelectCharacter/EnterWorld, so there is + // no policy id to switch on. ProbeHeadlessBotPolicy reports + // IsComplete unconditionally so HeadlessProcessScheduler treats + // this session as already finished the instant it is + // constructed, letting the scheduler's Run() loop return + // immediately for a probe-only process instead of waiting for + // SIGINT. policy = policyOverride - ?? HeadlessBotPolicyFactory.Create( - descriptor.Policy, - runtime, - () => _pendingConfirmation, - RespondToConfirmation, - gateCoordinator); + ?? (descriptor.Mode == HeadlessSessionMode.Probe + ? new ProbeHeadlessBotPolicy() + : HeadlessBotPolicyFactory.Create( + descriptor.Policy!, + runtime, + () => _pendingConfirmation, + RespondToConfirmation, + gateCoordinator)); policySubscription = runtime.Subscribe(policy); diagnostics.Lifecycle( descriptor.Id, @@ -354,9 +482,11 @@ internal sealed class HeadlessSessionHost : IDisposable _hostLease = hostLease; _policy = policy; _policySubscription = policySubscription; + _pluginSession = pluginSession; } catch { + pluginSession?.Dispose(); policySubscription?.Dispose(); policy?.Dispose(); hostLease?.Dispose(); @@ -379,6 +509,7 @@ internal sealed class HeadlessSessionHost : IDisposable /// production code uses to reach the same state. /// internal HeadlessCharacterOptionsSeeder? OptionsSeeder => _optionsSeeder; + internal HeadlessPluginSession Plugins => _pluginSession; internal string SessionId => _descriptor.Id; internal string ActiveCharacterName { get; private set; } = string.Empty; @@ -421,8 +552,17 @@ internal sealed class HeadlessSessionHost : IDisposable _pendingConfirmation = null; } - internal RuntimeSessionStartResult Start() => - Commands.Session.Start(Runtime.Generation); + internal RuntimeSessionStartResult Start() + { + // Campaign LA slice LA1: "started" = session host start — the + // earliest point this session actually attempts to connect. + _statusWriter.Started(_descriptor.Id); + _pluginSession.Start(); + RuntimeSessionStartResult result = + Commands.Session.Start(Runtime.Generation); + _startOutcome = result.Status; + return result; + } internal RuntimeSessionStartResult Reconnect() => Commands.Session.Reconnect(Runtime.Generation); @@ -435,7 +575,7 @@ internal sealed class HeadlessSessionHost : IDisposable _ = Runtime.Clock.Advance(deltaSeconds); _localPlayerFrame.AdvanceBeforeNetwork( checked((float)deltaSeconds)); - Runtime.Session.Tick(); + _liveSession.Tick(); // C3c: pump pending first-entry sequences after the network drain — // collision-generation progress and freshly accepted Creates both // surface here, mirroring the graphical per-frame retry phase. @@ -458,8 +598,9 @@ internal sealed class HeadlessSessionHost : IDisposable _policy.Tick(Runtime, Commands); } - internal RuntimeTeardownAcknowledgement Stop() + internal RuntimeTeardownAcknowledgement Stop(string reason = "stopped") { + ArgumentException.ThrowIfNullOrWhiteSpace(reason); RuntimeTeardownAcknowledgement result = Commands.Session.Stop(Runtime.Generation); // R9 review fix (2026-08-03): _currentSession is cached across @@ -470,6 +611,15 @@ internal sealed class HeadlessSessionHost : IDisposable // (possibly disposed) WorldSession in the window between this Stop // and the next CreateEventRoute call. _currentSession = null; + // Campaign LA slice LA1: only report a disconnect for a session that + // actually reached Connected — a Stop() on a never-started or + // never-connected host (e.g. immediate Dispose()) is not a real + // disconnect. + if (_hasConnected) + { + _hasConnected = false; + _statusWriter.Disconnected(_descriptor.Id, reason); + } return result; } @@ -572,26 +722,42 @@ internal sealed class HeadlessSessionHost : IDisposable _disposeStage++; break; case 4: - _hostLease.Dispose(); + _pluginSession.Dispose(); _disposeStage++; break; case 5: - _credential.Dispose(); + _hostLease.Dispose(); _disposeStage++; break; case 6: - Runtime.Dispose(); + _credential.Dispose(); _disposeStage++; break; case 7: - _contentLease?.Dispose(); + Runtime.Dispose(); _disposeStage++; break; case 8: + _contentLease?.Dispose(); + _disposeStage++; + break; + case 9: _diagnostics.Message( _descriptor.Id, "disposed", _stoppedGeneration); + // Campaign LA slice LA1: "exited" = terminal — the sole + // point every disposal path (graceful and post- + // quarantine) converges on. LA2: only an actual + // ProbeComplete start outcome reports reason "probe"; + // configured probe intent cannot turn a failed start into + // a successful terminal event. + (int exitCode, string exitReason) = + ResolveTerminalStatus(); + _statusWriter.Exited( + _descriptor.Id, + exitCode, + exitReason); _disposeStage++; _disposed = true; break; @@ -602,6 +768,33 @@ internal sealed class HeadlessSessionHost : IDisposable } } + /// + /// Produces the same terminal classification the owning process host uses. + /// Descriptor mode never participates: only an observed ProbeComplete may + /// report a successful probe. The surrounding disposal stage and LA1's + /// terminal/idempotent make this event + /// exact-once even when disposal is retried. + /// + private (int Code, string Reason) ResolveTerminalStatus() + { + if (_faulted) + { + return ( + (int)HeadlessExitCode.RuntimeError, + "runtime-fault"); + } + + return _startOutcome switch + { + RuntimeSessionStartStatus.ProbeComplete => + ((int)HeadlessExitCode.Success, "probe"), + null or RuntimeSessionStartStatus.Connected => + ((int)HeadlessExitCode.Success, "graceful"), + _ => + ((int)HeadlessExitCode.ConnectionError, "connection-error"), + }; + } + private RuntimeSessionStartResult StartCore( RuntimeGenerationToken expectedGeneration, bool reconnect) @@ -621,8 +814,12 @@ internal sealed class HeadlessSessionHost : IDisposable if (reconnect) { - RuntimeTeardownAcknowledgement stopped = - _liveSession.Stop(expectedGeneration); + // Campaign LA LA1 review fix F3: route reconnect teardown + // through the same status-aware Stop boundary as every other + // host stop. The retiring connection therefore publishes a + // truthful disconnected(reason: "reconnect") edge before the + // fresh LiveSessionHost reports its second connected edge. + RuntimeTeardownAcknowledgement stopped = Stop("reconnect"); if (!stopped.IsComplete) { return new RuntimeSessionStartResult( @@ -668,7 +865,8 @@ internal sealed class HeadlessSessionHost : IDisposable _descriptor.Endpoint.Port, _descriptor.Account, password, - MapCharacterSelector(_descriptor.Character)); + MapCharacterSelector(_descriptor.Character), + Probe: _descriptor.Mode == HeadlessSessionMode.Probe); LiveSessionStartResult result = _liveSession.Start(options); if (result.Selection is { } selection) _accountName = selection.AccountName; @@ -692,6 +890,186 @@ internal sealed class HeadlessSessionHost : IDisposable } } + private LiveChatCommandBindings CreateChatCommandBindings( + AcDream.Core.Net.WorldSession session, + GameRuntime runtime) => new( + ExecuteClientCommand: command => + ExecuteHeadlessClientCommand(session, runtime, command), + Communication: runtime.CommunicationOwner, + Chat: runtime.CommunicationOwner.Chat, + TurbineChat: runtime.CommunicationOwner.TurbineChat, + CharacterState: runtime.CharacterOwner, + PlayerGuid: () => runtime.PlayerIdentity.ServerGuid, + SendTalk: session.SendTalk, + SendTell: session.SendTell, + SendChannel: session.SendChannel, + SendTurbineChat: session.SendTurbineChatTo, + Log: message => _diagnostics.Message( + _descriptor.Id, + message, + runtime.Generation.Value)); + + /// + /// Presentation-free subset of retail client commands. Commands whose + /// semantics require a graphical confirmation/window or a host-specific + /// presentation service fail explicitly; the login sequence reports that + /// one line and continues. Wire-only and canonical-state commands take + /// the exact same WorldSession/Runtime paths as the graphical bindings. + /// + private static void ExecuteHeadlessClientCommand( + AcDream.Core.Net.WorldSession session, + GameRuntime runtime, + ExecuteClientCommandCmd command) + { + switch (command.Command) + { + case ClientCommandId.LifestoneRecall: + session.SendTeleportToLifestone(); + return; + case ClientCommandId.MarketplaceRecall: + session.SendTeleportToMarketplace(); + return; + case ClientCommandId.PkArenaRecall: + session.SendTeleportToPkArena(); + return; + case ClientCommandId.PkLiteArenaRecall: + session.SendTeleportToPkLiteArena(); + return; + case ClientCommandId.EnterPkLite: + session.SendEnterPkLite(); + return; + case ClientCommandId.HouseRecall: + session.SendTeleportToHouse(); + return; + case ClientCommandId.MansionRecall: + session.SendTeleportToMansion(); + return; + case ClientCommandId.QueryAge: + session.SendQueryAge(); + return; + case ClientCommandId.QueryBirth: + session.SendQueryBirth(); + return; + case ClientCommandId.Emote + when !string.IsNullOrWhiteSpace(command.Arguments): + session.SendEmote(command.Arguments.Trim()); + return; + case ClientCommandId.ClearChat: + runtime.CommunicationOwner.Chat.Clear(); + return; + case ClientCommandId.ChatToggle: + // Retail DoChatToggle: "off" adds the global Speech + // squelch; "on" removes it. + session.SendModifyGlobalSquelch( + command.Arguments.Equals( + "off", + StringComparison.OrdinalIgnoreCase), + 2u); + return; + case ClientCommandId.NoTellToggle: + // Retail DoNoTell: "on" adds the global Tell squelch; + // "off" removes it. + session.SendModifyGlobalSquelch( + command.Arguments.Equals( + "on", + StringComparison.OrdinalIgnoreCase), + 3u); + return; + case ClientCommandId.IndexChannels: + session.SendIndexChannels(); + return; + case ClientCommandId.ListChannel: + SendResolvedChannel( + command.Arguments, + session.SendListChannel); + return; + case ClientCommandId.OnChannel: + SendResolvedChannel( + command.Arguments, + session.SendOnChannel); + return; + case ClientCommandId.OffChannel: + SendResolvedChannel( + command.Arguments, + session.SendOffChannel); + return; + case ClientCommandId.AllegianceHometown: + session.SendRecallAllegianceHometown(); + return; + case ClientCommandId.AllegianceInfo: + session.SendAllegianceInfoRequest(command.Arguments.Trim()); + return; + case ClientCommandId.Permit: + ExecutePermit(session, command.Arguments); + return; + case ClientCommandId.HouseAvailableList + when RetailClientCommandCatalog.TryResolveHouseType( + command.Arguments, + out uint houseType): + session.SendListAvailableHouses(houseType); + return; + case ClientCommandId.JoinChannel + when RetailClientCommandCatalog.TryResolveJoinLeaveOption( + command.Arguments, + out uint joinOption): + _ = runtime.CharacterOwner.Options.TrySetOption( + joinOption, + true, + session.SendSetSingleCharacterOption); + return; + case ClientCommandId.LeaveChannel + when RetailClientCommandCatalog.TryResolveJoinLeaveOption( + command.Arguments, + out uint leaveOption): + _ = runtime.CharacterOwner.Options.TrySetOption( + leaveOption, + false, + session.SendSetSingleCharacterOption); + return; + default: + throw new NotSupportedException( + $"Client command '{command.Command}' is not available " + + "in the headless host."); + } + + static void SendResolvedChannel( + string arguments, + Action send) + { + if (!RetailChannelTagTable.TryResolve( + arguments.Trim(), + out uint channelId)) + { + throw new InvalidOperationException( + $"Chat channel '{arguments.Trim()}' does not exist."); + } + send(channelId); + } + + static void ExecutePermit( + AcDream.Core.Net.WorldSession activeSession, + string arguments) + { + // The catalog has already required add/remove plus a name. + // Match ClientCommandController's JoinArgsAsName behavior so + // multi-word character names remain one exact wire argument. + string[] parts = arguments.Split( + (char[]?)null, + StringSplitOptions.RemoveEmptyEntries); + string name = string.Join(' ', parts, 1, parts.Length - 1); + if (parts[0].Equals( + "add", + StringComparison.OrdinalIgnoreCase)) + { + activeSession.SendAddPlayerPermission(name); + } + else + { + activeSession.SendRemovePlayerPermission(name); + } + } + } + private ILiveSessionEventRouting CreateEventRoute( AcDream.Core.Net.WorldSession session) { @@ -915,12 +1293,19 @@ internal sealed class HeadlessSessionHost : IDisposable return declared; } - private static LiveSessionCharacterSelector MapCharacterSelector( - HeadlessCharacterSelector selector) => - new( - selector.Index, - selector.Id, - selector.Name); + /// Campaign LA slice LA2: for a probe + /// session (the loader guarantees Character is omitted whenever + /// Mode is ) — a probe + /// never reaches TrySelectCharacter, so "no selector configured" + /// is the correct, harmless mapping. + private static LiveSessionCharacterSelector? MapCharacterSelector( + HeadlessCharacterSelector? selector) => + selector is null + ? null + : new( + selector.Index, + selector.Id, + selector.Name); private RuntimeSessionStartResult Convert( LiveSessionStartResult result) @@ -939,6 +1324,8 @@ internal sealed class HeadlessSessionHost : IDisposable RuntimeSessionStartStatus.Deferred, LiveSessionStartStatus.Failed => RuntimeSessionStartStatus.Failed, + LiveSessionStartStatus.ProbeComplete => + RuntimeSessionStartStatus.ProbeComplete, _ => throw new ArgumentOutOfRangeException( nameof(result), result.Status, diff --git a/src/AcDream.Headless/Platform/HeadlessPathSet.cs b/src/AcDream.Headless/Platform/HeadlessPathSet.cs index 24059dae..e8e110e2 100644 --- a/src/AcDream.Headless/Platform/HeadlessPathSet.cs +++ b/src/AcDream.Headless/Platform/HeadlessPathSet.cs @@ -1,5 +1,5 @@ using AcDream.Headless.Configuration; -using AcDream.Runtime.Platform; +using AcDream.Platform; namespace AcDream.Headless.Platform; @@ -8,6 +8,9 @@ internal sealed record HeadlessPathSet( string DataDirectory, string CacheDirectory) { + internal string PluginsDirectory => + Path.Combine(DataDirectory, "plugins"); + internal static HeadlessPathSet Resolve( HeadlessPathOverrides overrides, IHeadlessPlatformEnvironment? platform = null) diff --git a/src/AcDream.Headless/Platform/HeadlessPlatformEnvironment.cs b/src/AcDream.Headless/Platform/HeadlessPlatformEnvironment.cs index 220f0571..27cced4a 100644 --- a/src/AcDream.Headless/Platform/HeadlessPlatformEnvironment.cs +++ b/src/AcDream.Headless/Platform/HeadlessPlatformEnvironment.cs @@ -1,4 +1,4 @@ -using AcDream.Runtime.Platform; +using AcDream.Platform; namespace AcDream.Headless.Platform; diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs new file mode 100644 index 00000000..ca5b6d5a --- /dev/null +++ b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs @@ -0,0 +1,265 @@ +using AcDream.Plugin.Abstractions; +using AcDream.Runtime; + +namespace AcDream.Headless.Plugins; + +/// +/// No-window plugin surface over one exact . State is +/// projected on demand from Runtime's canonical entity view, events come from +/// Runtime's ordered event source, and selection is the exact J5 action owner; +/// this adapter owns no gameplay mirror. +/// +internal sealed class HeadlessPluginHost + : IPluginHost, + IGameState, + IEvents, + IRuntimeEventObserver, + IDisposable +{ + private readonly GameRuntime _runtime; + private readonly IDisposable _eventSubscription; + private readonly object _eventGate = new(); + private readonly List _subscriptions = []; + private Subscription[] _liveSnapshot = []; + private bool _disposed; + + private readonly record struct ReplayEntity( + RuntimeEntityIdentity Identity, + WorldEntitySnapshot Snapshot); + + private sealed class Subscription(Action handler) + { + internal Action Handler { get; } = handler; + internal Queue Pending { get; } = new(); + internal HashSet Delivered { get; } = []; + internal bool Replaying { get; set; } = true; + internal bool Active { get; set; } = true; + } + + internal HeadlessPluginHost( + GameRuntime runtime, + IPluginLogger logger) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + Log = logger ?? throw new ArgumentNullException(nameof(logger)); + _eventSubscription = runtime.Subscribe(this); + } + + public bool HasUi => false; + public IPluginLogger Log { get; } + public IGameState State => this; + public IEvents Events => this; + public ISelectionService Selection => _runtime.ActionOwner.Selection; + public IUiRegistry Ui => NoOpUiRegistry.Instance; + + /// Test-only barrier invoked after the first replay item is + /// captured while Runtime's exact active-membership read lease is still + /// held. + internal Action? ReplayCapturedForTest { get; set; } + + /// + /// Immutable point-in-time values produced directly from Runtime on each + /// read. The caller owns the returned snapshot list; this host retains no + /// entity collection and therefore cannot become a second gameplay owner. + /// + public IReadOnlyList Entities + { + get + { + ObjectDisposedException.ThrowIf(_disposed, this); + var visitor = new SnapshotVisitor(_runtime); + _runtime.Entities.Visit(visitor); + return visitor.Items.Select(static item => item.Snapshot).ToArray(); + } + } + + public event Action EntitySpawned + { + add + { + ArgumentNullException.ThrowIfNull(value); + ObjectDisposedException.ThrowIf(_disposed, this); + var subscription = new Subscription(value); + lock (_eventGate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _subscriptions.Add(subscription); + } + + // Arm the pending queue before borrowing Runtime's snapshot. This + // avoids a host-lock/Runtime-lock inversion while the identity + // dedup below collapses any registration present in both views. + var visitor = new SnapshotVisitor( + _runtime, + ReplayCapturedForTest); + _runtime.Entities.Visit(visitor); + ReplayEntity[] replay = visitor.Items.ToArray(); + + foreach (ReplayEntity item in replay) + { + lock (_eventGate) + { + if (!subscription.Active) + return; + if (!_runtime.Entities.TryGet( + item.Identity.ServerGuid, + out RuntimeEntitySnapshot current) + || current.Identity != item.Identity + || !subscription.Delivered.Add(item.Identity)) + { + continue; + } + } + + Invoke(subscription.Handler, item.Snapshot); + } + + while (true) + { + ReplayEntity pending; + lock (_eventGate) + { + if (!subscription.Active) + return; + if (!subscription.Pending.TryDequeue(out pending)) + { + subscription.Replaying = false; + subscription.Delivered.Clear(); + RebuildLiveSnapshotLocked(); + return; + } + if (!subscription.Delivered.Add(pending.Identity)) + continue; + } + + Invoke(subscription.Handler, pending.Snapshot); + } + } + remove + { + if (value is null) + return; + lock (_eventGate) + { + for (int index = _subscriptions.Count - 1; index >= 0; index--) + { + Subscription subscription = _subscriptions[index]; + if (subscription.Handler != value) + continue; + subscription.Active = false; + subscription.Pending.Clear(); + subscription.Delivered.Clear(); + _subscriptions.RemoveAt(index); + if (!subscription.Replaying) + RebuildLiveSnapshotLocked(); + break; + } + } + } + } + + public void Dispose() + { + if (_disposed) + return; + lock (_eventGate) + { + _disposed = true; + foreach (Subscription subscription in _subscriptions) + { + subscription.Active = false; + subscription.Pending.Clear(); + subscription.Delivered.Clear(); + } + _subscriptions.Clear(); + _liveSnapshot = []; + } + _eventSubscription.Dispose(); + } + + public void OnEntity(in RuntimeEntityDelta delta) + { + if (delta.Change != RuntimeEntityChange.Registered) + return; + Subscription[] toNotify; + var pending = new ReplayEntity( + delta.Entity.Identity, + Convert(_runtime, delta.Entity)); + lock (_eventGate) + { + if (_disposed) + return; + foreach (Subscription subscription in _subscriptions) + { + if (subscription.Active && subscription.Replaying) + subscription.Pending.Enqueue(pending); + } + toNotify = _liveSnapshot; + } + if (toNotify.Length == 0) + return; + + foreach (Subscription subscription in toNotify) + Invoke(subscription.Handler, pending.Snapshot); + } + + public void OnLifecycle(in RuntimeLifecycleDelta delta) { } + public void OnCommand(in RuntimeCommandDelta delta) { } + public void OnInventory(in RuntimeInventoryDelta delta) { } + public void OnChat(in RuntimeChatDelta delta) { } + public void OnMovement(in RuntimeMovementDelta delta) { } + public void OnPortal(in RuntimePortalDelta delta) { } + public void OnCombat(in RuntimeCombatDelta delta) { } + + private static WorldEntitySnapshot Convert( + GameRuntime runtime, + in RuntimeEntitySnapshot entity) + { + uint sourceId = runtime.EntityObjects.Entities.TryGetActive( + entity.Identity.ServerGuid, + out AcDream.Runtime.Entities.RuntimeEntityRecord record) + ? record.Snapshot.SetupTableId ?? 0u + : 0u; + return new WorldEntitySnapshot( + entity.Identity.LocalEntityId, + sourceId, + entity.Position?.Frame.Origin ?? default, + entity.Position?.Frame.Orientation + ?? System.Numerics.Quaternion.Identity); + } + + private static void Invoke( + Action handler, + WorldEntitySnapshot snapshot) + { + try { handler(snapshot); } + catch { } + } + + private sealed class SnapshotVisitor( + GameRuntime runtime, + Action? captureBarrier = null) + : IRuntimeEntityVisitor + { + private Action? _captureBarrier = captureBarrier; + + internal List Items { get; } = + new(runtime.Entities.Count); + + public void Visit(in RuntimeEntitySnapshot entity) + { + Items.Add(new ReplayEntity( + entity.Identity, + Convert(runtime, entity))); + Interlocked.Exchange(ref _captureBarrier, null)?.Invoke(); + } + } + + private void RebuildLiveSnapshotLocked() + { + _liveSnapshot = _subscriptions + .Where(static subscription => + subscription.Active && !subscription.Replaying) + .ToArray(); + } +} diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginLogger.cs b/src/AcDream.Headless/Plugins/HeadlessPluginLogger.cs new file mode 100644 index 00000000..8b8494c2 --- /dev/null +++ b/src/AcDream.Headless/Plugins/HeadlessPluginLogger.cs @@ -0,0 +1,43 @@ +using AcDream.Headless.Diagnostics; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Headless.Plugins; + +internal sealed class HeadlessPluginLogger : IPluginLogger +{ + private readonly HeadlessDiagnosticWriter _diagnostics; + private readonly string _sessionId; + private readonly Func _generation; + + internal HeadlessPluginLogger( + HeadlessDiagnosticWriter diagnostics, + string sessionId, + Func generation) + { + _diagnostics = diagnostics + ?? throw new ArgumentNullException(nameof(diagnostics)); + _sessionId = sessionId + ?? throw new ArgumentNullException(nameof(sessionId)); + _generation = generation + ?? throw new ArgumentNullException(nameof(generation)); + } + + public void Info(string message) => Write("info", message); + public void Warn(string message) => Write("warn", message); + + public void Error(string message, Exception? exception = null) + { + if (exception is not null) + { + _diagnostics.Failure(_sessionId, "plugin", exception); + return; + } + Write("error", message); + } + + private void Write(string level, string message) => + _diagnostics.Message( + _sessionId, + $"plugin-{level}:{message}", + _generation()); +} diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs b/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs new file mode 100644 index 00000000..d199752b --- /dev/null +++ b/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs @@ -0,0 +1,120 @@ +using AcDream.Core.Plugins; +using AcDream.Headless.Diagnostics; +using AcDream.Plugin.Abstractions; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.Headless.Plugins; + +/// +/// Headless composition wrapper that keeps plugin disable/unsubscribe/unload +/// ahead of canonical Runtime disposal. +/// +internal sealed class HeadlessPluginSession : IDisposable +{ + private readonly HeadlessPluginHost _host; + private readonly PluginSession _plugins; + private readonly string[] _roots; + private readonly IReadOnlyList? _allowList; + private int _disposeStage; + private bool _started; + private bool _disposed; + + private HeadlessPluginSession( + HeadlessPluginHost host, + PluginSession plugins, + string[] roots, + IReadOnlyList? allowList) + { + _host = host; + _plugins = plugins; + _roots = roots; + _allowList = allowList; + } + + internal int LoadedCount => _plugins.LoadedCount; + internal HeadlessPluginHost Host => _host; + + internal IReadOnlyList CaptureLoadContextWeakReferences() => + _plugins.CaptureLoadContextWeakReferences(); + + internal static HeadlessPluginSession Create( + GameRuntime runtime, + HeadlessDiagnosticWriter diagnostics, + SessionStatusWriter statusWriter, + string sessionId, + IEnumerable roots, + IReadOnlyList? allowList) + { + ArgumentNullException.ThrowIfNull(runtime); + ArgumentNullException.ThrowIfNull(diagnostics); + ArgumentNullException.ThrowIfNull(statusWriter); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentNullException.ThrowIfNull(roots); + + var host = new HeadlessPluginHost( + runtime, + new HeadlessPluginLogger( + diagnostics, + sessionId, + () => runtime.Generation.Value)); + var plugins = new PluginSession( + host, + status => Report(statusWriter, sessionId, status)); + return new HeadlessPluginSession( + host, + plugins, + roots.ToArray(), + allowList); + } + + internal void Start() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_started) + throw new InvalidOperationException( + "The headless plugin session has already started."); + _started = true; + _plugins.Start(_roots, _allowList); + } + + public void Dispose() + { + if (_disposed) + return; + while (!_disposed) + { + switch (_disposeStage) + { + case 0: + _plugins.Dispose(); + _disposeStage++; + break; + case 1: + _host.Dispose(); + _disposeStage++; + _disposed = true; + break; + default: + throw new InvalidOperationException( + "Unknown headless plugin teardown stage."); + } + } + } + + private static void Report( + SessionStatusWriter writer, + string sessionId, + PluginSessionStatus status) + { + if (status.Kind == PluginSessionStatusKind.Loaded) + { + writer.PluginLoaded(sessionId, status.Plugin); + return; + } + writer.PluginFailed( + sessionId, + status.Plugin, + status.Error ?? "plugin failed"); + } +} diff --git a/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs b/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs index 90ee240c..f35c16a4 100644 --- a/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs +++ b/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs @@ -100,6 +100,21 @@ internal static class HeadlessBotPolicyFactory } } +/// +/// Campaign LA slice LA2: the "idle" consumer policy id — the session enters +/// world (unchanged start/select/EnterWorld +/// path) and then does nothing actively: no chat, no movement, no combat. +/// is permanently , so +/// keeps ticking the session +/// (harmlessly — and every delta handler below are no-ops) +/// until the process is stopped (SIGINT/cancellation) or disposed; teardown +/// then rides 's existing graceful +/// stop/logout path — the same mechanism K4's endurance gate already proved. +/// No is required. This class +/// predates LA2 (introduced at K1 as dev/test scaffolding); LA2 formalizes it +/// as the documented headless "just sit in world" play policy and adds +/// focused coverage in HeadlessBotPolicyTests. +/// internal sealed class IdleHeadlessBotPolicy : IHeadlessBotPolicy { public bool IsComplete => false; @@ -149,6 +164,70 @@ internal sealed class IdleHeadlessBotPolicy : IHeadlessBotPolicy } } +/// +/// Campaign LA slice LA2: the policy substituted (never selected via +/// — a probe session's +/// descriptor carries no policy id at all) for a +/// session. +/// is from construction, BEFORE +/// even runs, so +/// never dispatches a tick to this +/// session — a probe session's +/// is already gracefully torn down by +/// 's probe +/// short-circuit by the time the scheduler would otherwise look at it, and a +/// single-session probe process's Run() loop returns immediately +/// instead of waiting for SIGINT. +/// +internal sealed class ProbeHeadlessBotPolicy : IHeadlessBotPolicy +{ + public bool IsComplete => true; + + public void Tick( + IGameRuntimeView view, + IGameRuntimeCommands commands) + { + ArgumentNullException.ThrowIfNull(view); + ArgumentNullException.ThrowIfNull(commands); + } + + public void OnLifecycle(in RuntimeLifecycleDelta delta) + { + } + + public void OnCommand(in RuntimeCommandDelta delta) + { + } + + public void OnEntity(in RuntimeEntityDelta delta) + { + } + + public void OnInventory(in RuntimeInventoryDelta delta) + { + } + + public void OnChat(in RuntimeChatDelta delta) + { + } + + public void OnMovement(in RuntimeMovementDelta delta) + { + } + + public void OnPortal(in RuntimePortalDelta delta) + { + } + + public void OnCombat(in RuntimeCombatDelta delta) + { + } + + public void Dispose() + { + } +} + /// /// Explicit connected-gate policy: wait for the local player, issue one /// harmless local-speech command and one lifestone recall, reconnect after diff --git a/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj b/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj new file mode 100644 index 00000000..df9c9ad1 --- /dev/null +++ b/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj @@ -0,0 +1,22 @@ + + + net10.0 + enable + enable + latest + true + + true + + + + + + + + + diff --git a/src/AcDream.Launcher.Core/Installation/BakeOutputStagingContract.cs b/src/AcDream.Launcher.Core/Installation/BakeOutputStagingContract.cs new file mode 100644 index 00000000..cefa567e --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/BakeOutputStagingContract.cs @@ -0,0 +1,72 @@ +namespace AcDream.Launcher.Core.Installation; + +/// +/// Exact adjacent temporary-file contract emitted by AcDream.Bake's +/// BakeOutputTransaction. This class intentionally has no Bake project +/// dependency: both sides pin the same documented format with conformance +/// tests so Launcher.Core remains BCL-only. +/// +internal static class BakeOutputStagingContract +{ + internal const string StagingMarker = ".acdream-bake."; + private const string Suffix = ".tmp"; + + internal static string CreateStagingPath( + string destinationPath, + Guid transactionId) + { + string fullDestination = Path.GetFullPath(destinationPath); + string directory = Path.GetDirectoryName(fullDestination) + ?? throw new InvalidOperationException( + "The prepared package path has no parent directory."); + return Path.Combine( + directory, + $".{Path.GetFileName(fullDestination)}{StagingMarker}" + + $"{transactionId:N}{Suffix}"); + } + + internal static bool IsOwnedStagingFileName( + string fileName, + string destinationFileName) + { + string prefix = $".{destinationFileName}{StagingMarker}"; + if (!fileName.StartsWith(prefix, StringComparison.Ordinal) + || !fileName.EndsWith(Suffix, StringComparison.Ordinal) + || fileName.Length != prefix.Length + 32 + Suffix.Length) + { + return false; + } + + ReadOnlySpan transaction = fileName.AsSpan(prefix.Length, 32); + return Guid.TryParseExact(transaction, "N", out _); + } + + internal static void DeleteOwnedStagingFiles(string destinationPath) + { + string fullDestination = Path.GetFullPath(destinationPath); + string? directory = Path.GetDirectoryName(fullDestination); + if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory)) + { + return; + } + + string destinationFileName = Path.GetFileName(fullDestination); + try + { + foreach (string candidate in Directory.EnumerateFiles(directory)) + { + if (IsOwnedStagingFileName( + Path.GetFileName(candidate), + destinationFileName)) + { + LauncherInstallRecordStore.TryDelete(candidate); + } + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best effort: these files are never launchable. A later startup + // retries exact-name cleanup under the publication lock. + } + } +} diff --git a/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs b/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs new file mode 100644 index 00000000..24fbc48c --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs @@ -0,0 +1,198 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text; +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Installation; + +/// The exact child-process contract for one launcher bake. +public sealed record BakeProcessRequest( + string ExecutablePath, + string DatDirectory, + string OutputPath, + int Threads, + string? PublicationNonce = null) +{ + public IReadOnlyList Arguments => + [ + "--dat-dir", + DatDirectory, + "--out", + OutputPath, + "--threads", + Threads.ToString(CultureInfo.InvariantCulture), + "--progress-json", + ]; +} + +public sealed record BakeProcessResult(int ExitCode, string StandardError); + +/// +/// Injectable child seam. Stdout is delivered as arbitrary chunks so the +/// versioned JSONL parser, rather than line-oriented process plumbing, owns +/// partial-record behavior. +/// +public interface IBakeProcessRunner +{ + Task RunAsync( + BakeProcessRequest request, + Action onStandardOutput, + CancellationToken cancellationToken = default); +} + +public sealed class SystemBakeProcessRunner : IBakeProcessRunner +{ + private const int BufferSize = 4096; + private const int MaximumCapturedErrorCharacters = 32 * 1024; + + public async Task RunAsync( + BakeProcessRequest request, + Action onStandardOutput, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(onStandardOutput); + if (request.Threads <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(request), + "Bake thread count must be positive."); + } + + cancellationToken.ThrowIfCancellationRequested(); + + ProcessStartInfo startInfo = CreateStartInfo(request); + + using var process = new Process { StartInfo = startInfo }; + if (!process.Start()) + { + throw new InvalidOperationException("The bake process could not be started."); + } + + // The bake consumes no credential or other stdin input. + process.StandardInput.Close(); + + var standardError = new StringBuilder(); + Task stdoutPump = PumpAsync( + process.StandardOutput, + onStandardOutput, + CancellationToken.None); + Task stderrPump = PumpAsync( + process.StandardError, + chunk => AppendBounded(standardError, chunk), + CancellationToken.None); + + using CancellationTokenRegistration cancellation = cancellationToken.Register( + static state => + { + var child = (Process)state!; + try + { + if (!child.HasExited) + { + child.Kill(entireProcessTree: true); + } + } + catch + { + // The cancellation token remains authoritative. Races with + // natural exit or handle teardown do not replace it. + } + }, + process); + + try + { + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + await Task.WhenAll(stdoutPump, stderrPump).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return new BakeProcessResult(process.ExitCode, standardError.ToString()); + } + catch (OperationCanceledException) + { + try + { + using var cleanupTimeout = new CancellationTokenSource( + TimeSpan.FromSeconds(5)); + await process.WaitForExitAsync(cleanupTimeout.Token) + .ConfigureAwait(false); + await Task.WhenAll(stdoutPump, stderrPump) + .WaitAsync(cleanupTimeout.Token) + .ConfigureAwait(false); + } + catch + { + // Preserve cancellation. The process kill registration above + // already made the best effort to terminate the tree. + } + + throw; + } + } + + internal static ProcessStartInfo CreateStartInfo(BakeProcessRequest request) + { + ArgumentNullException.ThrowIfNull(request); + var startInfo = new ProcessStartInfo + { + FileName = request.ExecutablePath, + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + foreach (string argument in request.Arguments) + { + startInfo.ArgumentList.Add(argument); + } + startInfo.Environment.Remove( + BakePublicationGuardPaths.NonceEnvironmentVariable); + if (request.PublicationNonce is not null) + { + if (!BakePublicationGuardPaths.IsValidNonce( + request.PublicationNonce)) + { + throw new ArgumentException( + "The bake publication nonce is invalid.", + nameof(request)); + } + + startInfo.Environment[ + BakePublicationGuardPaths.NonceEnvironmentVariable] = + request.PublicationNonce; + } + + return startInfo; + } + + private static async Task PumpAsync( + TextReader reader, + Action sink, + CancellationToken cancellationToken) + { + char[] buffer = new char[BufferSize]; + while (true) + { + int read = await reader.ReadAsync(buffer, cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + return; + } + + sink(new string(buffer, 0, read)); + } + } + + private static void AppendBounded(StringBuilder destination, string chunk) + { + int remaining = MaximumCapturedErrorCharacters - destination.Length; + if (remaining <= 0) + { + return; + } + + destination.Append(chunk.AsSpan(0, Math.Min(remaining, chunk.Length))); + } +} diff --git a/src/AcDream.Launcher.Core/Installation/BakeProgressEvent.cs b/src/AcDream.Launcher.Core/Installation/BakeProgressEvent.cs new file mode 100644 index 00000000..608c602e --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/BakeProgressEvent.cs @@ -0,0 +1,54 @@ +namespace AcDream.Launcher.Core.Installation; + +/// +/// Versioned machine-readable output from acdream-bake +/// --progress-json. Human output shares stdout but remains a distinct +/// event so the installer never derives state by scraping prose. +/// +public abstract record BakeProgressEvent(int Version, string EventName); + +public sealed record BakeStartedEvent( + int Version, + uint BakeToolVersion, + string? OutputPath) + : BakeProgressEvent(Version, "started"); + +public sealed record BakeWorkProgressEvent( + int Version, + string Phase, + long Completed, + long Total, + int Failures, + double ElapsedSeconds, + double EtaSeconds) + : BakeProgressEvent(Version, "progress"); + +public sealed record BakeCompletedEvent( + int Version, + uint BakeToolVersion, + long OutputBytes, + int Failures) + : BakeProgressEvent(Version, "completed"); + +public sealed record BakeErrorEvent(int Version, string Message) + : BakeProgressEvent(Version, "error"); + +public sealed record UnknownBakeProgressEvent( + int Version, + string EventName, + string RawLine) + : BakeProgressEvent(Version, EventName); + +public sealed record FutureBakeProgressEvent( + int Version, + string EventName, + string RawLine) + : BakeProgressEvent(Version, EventName); + +public sealed record MalformedBakeProgressEvent( + string RawLine, + string Reason) + : BakeProgressEvent(0, "malformed"); + +public sealed record BakeHumanOutputEvent(string Text) + : BakeProgressEvent(0, "human"); diff --git a/src/AcDream.Launcher.Core/Installation/BakeProgressJsonlParser.cs b/src/AcDream.Launcher.Core/Installation/BakeProgressJsonlParser.cs new file mode 100644 index 00000000..cc4cafb5 --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/BakeProgressJsonlParser.cs @@ -0,0 +1,230 @@ +using System.Text; +using System.Text.Json; + +namespace AcDream.Launcher.Core.Installation; + +/// +/// Incremental JSONL parser tolerant of arbitrary stream chunk boundaries. +/// Unknown event names and future protocol versions stay observable without +/// failing the bake; malformed known payloads are explicit typed events. +/// +public sealed class BakeProgressJsonlParser +{ + public const int CurrentVersion = 1; + + private readonly StringBuilder _pending = new(); + + public IReadOnlyList Append(string chunk) + { + ArgumentNullException.ThrowIfNull(chunk); + _pending.Append(chunk); + return Drain(completeFinalLine: false); + } + + public IReadOnlyList Complete() => + Drain(completeFinalLine: true); + + public static BakeProgressEvent ParseLine(string line) + { + ArgumentNullException.ThrowIfNull(line); + string trimmed = line.Trim(); + if (trimmed.Length == 0) + { + return new BakeHumanOutputEvent(string.Empty); + } + + if (trimmed[0] != '{') + { + return new BakeHumanOutputEvent(line.TrimEnd('\r')); + } + + try + { + using JsonDocument document = JsonDocument.Parse(trimmed); + JsonElement root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object + || !TryGetInt32(root, "v", out int version) + || !TryGetString(root, "e", out string? eventName)) + { + return Malformed(line, "JSON progress requires integer 'v' and string 'e'."); + } + + if (version != CurrentVersion) + { + return new FutureBakeProgressEvent(version, eventName!, line); + } + + return eventName switch + { + "started" => ParseStarted(root, version, line), + "progress" => ParseProgress(root, version, line), + "completed" => ParseCompleted(root, version, line), + "error" => ParseError(root, version, line), + _ => new UnknownBakeProgressEvent(version, eventName!, line), + }; + } + catch (JsonException ex) + { + return Malformed(line, ex.Message); + } + } + + private IReadOnlyList Drain(bool completeFinalLine) + { + var events = new List(); + while (true) + { + int newline = IndexOfNewline(_pending); + if (newline < 0) + { + break; + } + + string line = _pending.ToString(0, newline); + _pending.Remove(0, newline + 1); + events.Add(ParseLine(line)); + } + + if (completeFinalLine && _pending.Length > 0) + { + string line = _pending.ToString(); + _pending.Clear(); + events.Add(ParseLine(line)); + } + + return events; + } + + private static int IndexOfNewline(StringBuilder value) + { + for (int i = 0; i < value.Length; i++) + { + if (value[i] == '\n') + { + return i; + } + } + + return -1; + } + + private static BakeProgressEvent ParseStarted( + JsonElement root, + int version, + string raw) + { + if (!TryGetUInt32(root, "bakeToolVersion", out uint bakeToolVersion) + || bakeToolVersion == 0) + { + return Malformed(raw, "started requires a positive bakeToolVersion."); + } + + _ = TryGetString(root, "outputPath", out string? outputPath); + return new BakeStartedEvent(version, bakeToolVersion, outputPath); + } + + private static BakeProgressEvent ParseProgress( + JsonElement root, + int version, + string raw) + { + if (!TryGetString(root, "phase", out string? phase) + || !TryGetInt64(root, "completed", out long completed) + || !TryGetInt64(root, "total", out long total) + || !TryGetInt32(root, "failures", out int failures) + || !TryGetDouble(root, "elapsedSeconds", out double elapsedSeconds) + || !TryGetDouble(root, "etaSeconds", out double etaSeconds) + || completed < 0 + || total < 0 + || completed > total + || failures < 0 + || elapsedSeconds < 0 + || etaSeconds < 0) + { + return Malformed(raw, "progress payload has missing or invalid fields."); + } + + return new BakeWorkProgressEvent( + version, + phase!, + completed, + total, + failures, + elapsedSeconds, + etaSeconds); + } + + private static BakeProgressEvent ParseCompleted( + JsonElement root, + int version, + string raw) + { + if (!TryGetUInt32(root, "bakeToolVersion", out uint bakeToolVersion) + || !TryGetInt64(root, "outputBytes", out long outputBytes) + || !TryGetInt32(root, "failures", out int failures) + || bakeToolVersion == 0 + || outputBytes <= 0 + || failures < 0) + { + return Malformed(raw, "completed payload has missing or invalid fields."); + } + + return new BakeCompletedEvent( + version, + bakeToolVersion, + outputBytes, + failures); + } + + private static BakeProgressEvent ParseError( + JsonElement root, + int version, + string raw) => + TryGetString(root, "message", out string? message) + && !string.IsNullOrWhiteSpace(message) + ? new BakeErrorEvent(version, message) + : Malformed(raw, "error requires a non-empty message."); + + private static MalformedBakeProgressEvent Malformed(string raw, string reason) => + new(raw, reason); + + private static bool TryGetString( + JsonElement root, + string name, + out string? value) + { + value = null; + return root.TryGetProperty(name, out JsonElement element) + && element.ValueKind == JsonValueKind.String + && (value = element.GetString()) is not null; + } + + private static bool TryGetInt32(JsonElement root, string name, out int value) + { + value = default; + return root.TryGetProperty(name, out JsonElement element) + && element.TryGetInt32(out value); + } + + private static bool TryGetUInt32(JsonElement root, string name, out uint value) + { + value = default; + return root.TryGetProperty(name, out JsonElement element) + && element.TryGetUInt32(out value); + } + + private static bool TryGetInt64(JsonElement root, string name, out long value) + { + value = default; + return root.TryGetProperty(name, out JsonElement element) + && element.TryGetInt64(out value); + } + + private static bool TryGetDouble(JsonElement root, string name, out double value) + { + value = default; + return root.TryGetProperty(name, out JsonElement element) + && element.TryGetDouble(out value) + && double.IsFinite(value); + } +} diff --git a/src/AcDream.Launcher.Core/Installation/BakeProgressProtocol.cs b/src/AcDream.Launcher.Core/Installation/BakeProgressProtocol.cs new file mode 100644 index 00000000..65d5eb92 --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/BakeProgressProtocol.cs @@ -0,0 +1,121 @@ +namespace AcDream.Launcher.Core.Installation; + +/// +/// Strict state machine for known v1 bake events. Human output, unknown v1 +/// events, and future versions are deliberately transparent; known v1 events +/// cannot be reordered, duplicated, or appended after the first terminal. +/// +internal sealed class BakeProgressProtocol +{ + private BakeProgressProtocolState _state; + + internal BakeStartedEvent? Started { get; private set; } + + internal BakeCompletedEvent? Completed { get; private set; } + + internal BakeErrorEvent? Error { get; private set; } + + internal string? Violation { get; private set; } + + internal bool Observe(BakeProgressEvent progressEvent) + { + ArgumentNullException.ThrowIfNull(progressEvent); + if (Violation is not null) + { + return false; + } + + switch (progressEvent) + { + case BakeHumanOutputEvent: + case UnknownBakeProgressEvent: + case FutureBakeProgressEvent: + return true; + case MalformedBakeProgressEvent malformed: + Reject($"Malformed bake progress: {malformed.Reason}"); + return false; + case BakeStartedEvent started: + if (_state != BakeProgressProtocolState.AwaitingStarted) + { + Reject(_state == BakeProgressProtocolState.Running + ? "The bake protocol emitted more than one v1 started event." + : "The bake protocol emitted a known event after its terminal event."); + return false; + } + + Started = started; + _state = BakeProgressProtocolState.Running; + return true; + case BakeWorkProgressEvent: + if (_state != BakeProgressProtocolState.Running) + { + Reject(KnownEventStateViolation("progress")); + return false; + } + + return true; + case BakeCompletedEvent completed: + if (_state != BakeProgressProtocolState.Running) + { + Reject(KnownEventStateViolation("completed")); + return false; + } + + Completed = completed; + _state = BakeProgressProtocolState.Completed; + return true; + case BakeErrorEvent error: + if (_state != BakeProgressProtocolState.Running) + { + Reject(KnownEventStateViolation("error")); + return false; + } + + Error = error; + _state = BakeProgressProtocolState.Error; + return true; + default: + Reject("The bake protocol emitted an unsupported known event."); + return false; + } + } + + internal void CompleteInput() + { + if (Violation is not null) + { + return; + } + + if (_state == BakeProgressProtocolState.AwaitingStarted) + { + Reject("The bake protocol did not emit a v1 started event first."); + } + else if (_state == BakeProgressProtocolState.Running) + { + Reject("The bake protocol ended without exactly one terminal event."); + } + } + + private string KnownEventStateViolation(string eventName) => _state switch + { + BakeProgressProtocolState.AwaitingStarted => + $"The bake protocol emitted v1 {eventName} before v1 started.", + BakeProgressProtocolState.Running => + $"The bake protocol emitted an invalid v1 {eventName} event.", + _ => "The bake protocol emitted a known event after its terminal event.", + }; + + private void Reject(string message) + { + Violation ??= message; + } + + private enum BakeProgressProtocolState + { + AwaitingStarted, + Running, + Completed, + Error, + } +} diff --git a/src/AcDream.Launcher.Core/Installation/BakePublicationGuardContract.cs b/src/AcDream.Launcher.Core/Installation/BakePublicationGuardContract.cs new file mode 100644 index 00000000..a3f409c6 --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/BakePublicationGuardContract.cs @@ -0,0 +1,120 @@ +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Installation; + +/// +/// Launcher half of the environment-only Bake publication guard. Paths are +/// derived from the canonical output path, while a durable GUID nonce grants +/// one child permission to promote its already-validated adjacent staging +/// file. Every token mutation happens while the stable publication lock is +/// held. +/// +internal static class BakePublicationGuardContract +{ + private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(50); + + internal static async ValueTask AcquireAsync( + string outputPath, + CancellationToken cancellationToken = default) + { + string lockPath = BakePublicationGuardPaths.GetPublishLockPath( + outputPath); + Directory.CreateDirectory( + Path.GetDirectoryName(lockPath) + ?? throw new InvalidOperationException( + "The bake publication lock has no parent directory.")); + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + return new PublicationLease(new FileStream( + lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + options: FileOptions.None)); + } + catch (IOException) + { + await Task.Delay(RetryDelay, cancellationToken) + .ConfigureAwait(false); + } + } + } + + internal static void Authorize( + string outputPath, + string nonce, + PublicationLease lease) + { + ArgumentNullException.ThrowIfNull(lease); + if (!BakePublicationGuardPaths.IsValidNonce(nonce)) + { + throw new ArgumentException( + "The bake publication nonce must be a lowercase GUID in N format.", + nameof(nonce)); + } + + string authorizationPath = + BakePublicationGuardPaths.GetAuthorizationPath(outputPath); + using var stream = new FileStream( + authorizationPath, + FileMode.Create, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + options: FileOptions.WriteThrough); + using var writer = new StreamWriter(stream, leaveOpen: true); + writer.Write(nonce); + writer.Flush(); + stream.Flush(flushToDisk: true); + } + + internal static void Invalidate( + string outputPath, + PublicationLease lease, + string? onlyIfNonceMatches = null) + { + ArgumentNullException.ThrowIfNull(lease); + string authorizationPath = + BakePublicationGuardPaths.GetAuthorizationPath(outputPath); + if (!File.Exists(authorizationPath)) + { + return; + } + + if (onlyIfNonceMatches is not null) + { + string current = File.ReadAllText(authorizationPath); + + if (!string.Equals( + current, + onlyIfNonceMatches, + StringComparison.Ordinal)) + { + return; + } + } + + File.Delete(authorizationPath); + } + + internal sealed class PublicationLease : IAsyncDisposable + { + private readonly FileStream _stream; + + internal PublicationLease(FileStream stream) + { + _stream = stream; + } + + public ValueTask DisposeAsync() + { + _stream.Dispose(); + return ValueTask.CompletedTask; + } + } +} diff --git a/src/AcDream.Launcher.Core/Installation/DatDirectoryLocator.cs b/src/AcDream.Launcher.Core/Installation/DatDirectoryLocator.cs new file mode 100644 index 00000000..72096a4f --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/DatDirectoryLocator.cs @@ -0,0 +1,138 @@ +namespace AcDream.Launcher.Core.Installation; + +/// +/// Portable validation and Windows-only discovery for the four retail data +/// archives consumed by acdream-bake. Discovery is intentionally only +/// a list of conventional paths; validation is the same filesystem operation +/// on Windows and Linux, including for a manually entered path. +/// +public sealed class DatDirectoryLocator +{ + public static IReadOnlyList RequiredFileNames { get; } = + Array.AsReadOnly( + [ + "client_portal.dat", + "client_cell_1.dat", + "client_highres.dat", + "client_local_English.dat", + ]); + + private readonly bool _isWindows; + private readonly string[] _windowsCandidates; + private readonly Func _directoryExists; + private readonly Func _fileExists; + + public DatDirectoryLocator( + bool? isWindows = null, + IEnumerable? windowsCandidates = null, + Func? directoryExists = null, + Func? fileExists = null) + { + _isWindows = isWindows ?? OperatingSystem.IsWindows(); + _windowsCandidates = (windowsCandidates ?? DefaultWindowsCandidates()) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(Path.GetFullPath) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + _directoryExists = directoryExists ?? Directory.Exists; + _fileExists = fileExists ?? File.Exists; + } + + /// + /// Returns conventional Windows locations that actually exist, in + /// preference order. An existing but incomplete directory remains in the + /// result so the wizard can explain exactly which DATs are missing. + /// Linux returns an empty list and relies on the manual picker/path field. + /// + public IReadOnlyList Detect() + { + if (!_isWindows) + { + return []; + } + + return _windowsCandidates + .Where(_directoryExists) + .Select(Validate) + .ToArray(); + } + + public DatDirectoryValidation Validate(string? directory) + { + if (string.IsNullOrWhiteSpace(directory)) + { + return DatDirectoryValidation.Invalid( + directory ?? string.Empty, + "Choose the folder containing the retail DAT files.", + RequiredFileNames); + } + + string fullPath; + try + { + fullPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)); + } + catch (Exception ex) when (ex is ArgumentException + or NotSupportedException + or PathTooLongException) + { + return DatDirectoryValidation.Invalid( + directory, + "The DAT directory path is not valid.", + RequiredFileNames); + } + + if (!_directoryExists(fullPath)) + { + return DatDirectoryValidation.Invalid( + fullPath, + "The DAT directory does not exist.", + RequiredFileNames); + } + + string[] missing = RequiredFileNames + .Where(fileName => !_fileExists(Path.Combine(fullPath, fileName))) + .ToArray(); + return missing.Length == 0 + ? DatDirectoryValidation.Valid(fullPath) + : DatDirectoryValidation.Invalid( + fullPath, + "The selected directory is missing required retail DAT files.", + missing); + } + + private static IEnumerable DefaultWindowsCandidates() + { + string userProfile = Environment.GetFolderPath( + Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrWhiteSpace(userProfile)) + { + yield return Path.Combine( + userProfile, + "Documents", + "Asheron's Call"); + } + + yield return @"C:\Turbine\Asheron's Call"; + } +} + +public sealed record DatDirectoryValidation( + string Directory, + bool IsValid, + string Message, + IReadOnlyList MissingFileNames) +{ + internal static DatDirectoryValidation Valid(string directory) => + new( + directory, + true, + "All four required retail DAT files were found.", + []); + + internal static DatDirectoryValidation Invalid( + string directory, + string message, + IReadOnlyList missingFileNames) => + new(directory, false, message, missingFileNames); +} diff --git a/src/AcDream.Launcher.Core/Installation/InstallerTransactionLease.cs b/src/AcDream.Launcher.Core/Installation/InstallerTransactionLease.cs new file mode 100644 index 00000000..1fc7ec8e --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/InstallerTransactionLease.cs @@ -0,0 +1,60 @@ +namespace AcDream.Launcher.Core.Installation; + +/// +/// Cross-process ownership for every mutation or recovery of one launcher +/// DataDirectory. The persistent lock pathname is harmless; exclusivity is +/// owned by the open OS handle and therefore disappears if the process dies. +/// +internal sealed class InstallerTransactionLease : IAsyncDisposable +{ + internal const string LockFileName = ".install.lock"; + private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(50); + + private readonly FileStream _stream; + + private InstallerTransactionLease(FileStream stream) + { + _stream = stream; + } + + internal static string GetLockPath(string dataDirectory) => + Path.Combine(Path.GetFullPath(dataDirectory), LockFileName); + + internal static async ValueTask AcquireAsync( + string dataDirectory, + CancellationToken cancellationToken = default) + { + string lockPath = GetLockPath(dataDirectory); + Directory.CreateDirectory( + Path.GetDirectoryName(lockPath) + ?? throw new InvalidOperationException( + "The installer lock path has no parent directory.")); + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var stream = new FileStream( + lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + FileOptions.None); + return new InstallerTransactionLease(stream); + } + catch (IOException) + { + await Task.Delay(RetryDelay, cancellationToken) + .ConfigureAwait(false); + } + } + } + + public ValueTask DisposeAsync() + { + _stream.Dispose(); + return ValueTask.CompletedTask; + } +} diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs new file mode 100644 index 00000000..f88dba2f --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs @@ -0,0 +1,488 @@ +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 enum InstallRecordVerificationState +{ + Missing, + Verified, + Invalid, +} + +public sealed record InstallRecordVerification( + InstallRecordVerificationState State, + LauncherInstallRecord? Record, + string Status) +{ + public bool IsVerified => State == InstallRecordVerificationState.Verified; +} + +/// +/// Versioned install-record persistence and startup verification. The record +/// is atomically replaced only after a complete package has been hashed; a +/// crash during a reinstall can recover the prior verified pak from the +/// adjacent backup before launch is enabled. +/// +public sealed class LauncherInstallRecordStore +{ + public const uint CurrentBakeToolVersion = 4; + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + }; + + private readonly ApplicationPathSet _paths; + private readonly DatDirectoryLocator _datDirectories; + private readonly Func> _computeSha256; + + public LauncherInstallRecordStore( + ApplicationPathSet paths, + DatDirectoryLocator? datDirectories = null, + Func>? computeSha256 = null) + { + _paths = paths ?? throw new ArgumentNullException(nameof(paths)); + _datDirectories = datDirectories ?? new DatDirectoryLocator(); + _computeSha256 = computeSha256 + ?? ((path, cancellationToken) => + FileIntegrity.ComputeSha256HexAsync(path, cancellationToken)); + } + + public string DataDirectory => Path.GetFullPath(_paths.DataDirectory); + + public string RecordPath => Path.Combine(DataDirectory, "install.json"); + + public string PreparedAssetPath => Path.Combine( + DataDirectory, + "pak", + "acdream.pak"); + + public static string GetBackupPath(string preparedAssetPath) => + preparedAssetPath + ".previous-install"; + + public async Task LoadAndVerifyAsync( + CancellationToken cancellationToken = default) + { + await using InstallerTransactionLease lease = + await InstallerTransactionLease.AcquireAsync( + DataDirectory, + cancellationToken) + .ConfigureAwait(false); + return await LoadAndVerifyUnderLeaseAsync(cancellationToken) + .ConfigureAwait(false); + } + + internal async Task LoadAndVerifyUnderLeaseAsync( + CancellationToken cancellationToken = default) + { + if (!File.Exists(RecordPath)) + { + return new InstallRecordVerification( + InstallRecordVerificationState.Missing, + null, + "Client content is not installed. Complete the first-run setup."); + } + + LauncherInstallRecord? record; + try + { + await using FileStream stream = new( + RecordPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 4096, + options: FileOptions.Asynchronous | FileOptions.SequentialScan); + using JsonDocument document = await JsonDocument.ParseAsync( + stream, + cancellationToken: cancellationToken) + .ConfigureAwait(false); + JsonElement root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("version", out JsonElement version) + || !version.TryGetInt32(out _)) + { + return Invalid( + "The install record must contain an explicit integer version."); + } + + record = root.Deserialize(SerializerOptions); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or JsonException + or NotSupportedException) + { + return Invalid($"The install record could not be read: {ex.Message}"); + } + + if (record is null) + { + return Invalid("The install record is empty."); + } + + string? contractError = ValidateRecordContract( + record, + requireCanonicalSerializedPaths: true); + if (contractError is not null) + { + return Invalid(contractError); + } + + string backupPath = GetBackupPath(record.PreparedAssetPath); + FileVerification current = await VerifyFileAsync( + record.PreparedAssetPath, + record, + cancellationToken) + .ConfigureAwait(false); + if (current.IsValid) + { + TryDelete(backupPath); + return Verified(record); + } + + // A process crash may occur after the old verified package was moved + // aside but before the replacement record was published. Verify the + // backup against the still-current record before restoring it. + FileVerification backup = await VerifyFileAsync( + backupPath, + record, + cancellationToken) + .ConfigureAwait(false); + if (backup.IsValid) + { + try + { + Directory.CreateDirectory( + Path.GetDirectoryName(record.PreparedAssetPath) + ?? throw new InvalidOperationException( + "The prepared asset path has no parent directory.")); + File.Move(backupPath, record.PreparedAssetPath, overwrite: true); + return Verified(record, "Recovered and verified the previous client content."); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return Invalid( + $"The previous verified package could not be restored: {ex.Message}"); + } + } + + return Invalid(current.Status); + } + + public async Task SaveAtomicallyAsync( + LauncherInstallRecord record, + CancellationToken cancellationToken = default) + { + await using InstallerTransactionLease lease = + await InstallerTransactionLease.AcquireAsync( + DataDirectory, + cancellationToken) + .ConfigureAwait(false); + await SaveAtomicallyUnderLeaseAsync(record, cancellationToken) + .ConfigureAwait(false); + } + + internal async Task SaveAtomicallyUnderLeaseAsync( + LauncherInstallRecord record, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(record); + LauncherInstallRecord normalized = NormalizeForSave(record); + string? contractError = ValidateRecordContract( + normalized, + requireCanonicalSerializedPaths: true); + if (contractError is not null) + { + throw new InvalidDataException(contractError); + } + + string directory = Path.GetDirectoryName(RecordPath) + ?? throw new InvalidOperationException( + "The install record has no parent directory."); + Directory.CreateDirectory(directory); + string temporaryPath = Path.Combine( + directory, + $".{Path.GetFileName(RecordPath)}.{Guid.NewGuid():N}.tmp"); + + try + { + await using (FileStream stream = new( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + options: FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + await JsonSerializer.SerializeAsync( + stream, + normalized, + SerializerOptions, + cancellationToken) + .ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + stream.Flush(flushToDisk: true); + } + + cancellationToken.ThrowIfCancellationRequested(); + File.Move(temporaryPath, RecordPath, overwrite: true); + } + finally + { + TryDelete(temporaryPath); + } + } + + private string? ValidateRecordContract( + LauncherInstallRecord record, + bool requireCanonicalSerializedPaths) + { + if (record.Version != LauncherInstallRecord.CurrentRecordVersion) + { + return $"Install record version {record.Version} is not supported."; + } + + if (!record.HasIntegrityMetadata) + { + return "The install record is missing SHA-256, size, or bake-tool metadata."; + } + + if (record.BakeToolVersion != CurrentBakeToolVersion) + { + return $"Bake tool version {record.BakeToolVersion} is not supported; " + + $"version {CurrentBakeToolVersion} is required."; + } + + if (!IsSha256(record.PreparedAssetSha256)) + { + return "The install record contains an invalid SHA-256 digest."; + } + + if (string.IsNullOrWhiteSpace(record.PreparedAssetPath)) + { + return "The prepared asset path is missing."; + } + + if (string.IsNullOrWhiteSpace(record.DatDirectory)) + { + return "The DAT directory path is missing."; + } + + string canonicalPreparedPath = Path.GetFullPath(PreparedAssetPath); + if (requireCanonicalSerializedPaths + && !Path.IsPathFullyQualified(record.PreparedAssetPath)) + { + return "The prepared asset path must be absolute."; + } + + string recordedPreparedPath; + try + { + recordedPreparedPath = Path.GetFullPath(record.PreparedAssetPath); + } + catch (Exception ex) when (ex is ArgumentException + or NotSupportedException + or PathTooLongException) + { + return $"The prepared asset path is invalid: {ex.Message}"; + } + + if (!PathsEqual(recordedPreparedPath, canonicalPreparedPath)) + { + return "The install record does not point to the launcher's canonical " + + "DataDirectory/pak/acdream.pak path."; + } + + if (requireCanonicalSerializedPaths + && !CanonicalSpellingEquals( + record.PreparedAssetPath, + recordedPreparedPath, + trimEndingSeparator: false)) + { + return "The prepared asset path is not canonical."; + } + + if (requireCanonicalSerializedPaths + && !Path.IsPathFullyQualified(record.DatDirectory)) + { + return "The DAT directory path must be absolute."; + } + + DatDirectoryValidation datValidation = + _datDirectories.Validate(record.DatDirectory); + if (!datValidation.IsValid) + { + return datValidation.Message + + FormatMissing(datValidation.MissingFileNames); + } + + return requireCanonicalSerializedPaths + && !CanonicalSpellingEquals( + record.DatDirectory, + datValidation.Directory, + trimEndingSeparator: true) + ? "The DAT directory path is not canonical." + : null; + } + + private LauncherInstallRecord NormalizeForSave(LauncherInstallRecord record) + { + if (record.Version != LauncherInstallRecord.CurrentRecordVersion) + { + throw new InvalidDataException( + $"Install record version {record.Version} is not supported."); + } + + if (string.IsNullOrWhiteSpace(record.PreparedAssetPath)) + { + throw new InvalidDataException("The prepared asset path is missing."); + } + + DatDirectoryValidation datValidation = + _datDirectories.Validate(record.DatDirectory); + if (!datValidation.IsValid) + { + throw new InvalidDataException( + datValidation.Message + + FormatMissing(datValidation.MissingFileNames)); + } + + string recordedPreparedPath; + try + { + recordedPreparedPath = Path.GetFullPath(record.PreparedAssetPath); + } + catch (Exception ex) when (ex is ArgumentException + or NotSupportedException + or PathTooLongException) + { + throw new InvalidDataException( + $"The prepared asset path is invalid: {ex.Message}", + ex); + } + + if (!PathsEqual(recordedPreparedPath, PreparedAssetPath)) + { + throw new InvalidDataException( + "The install record does not point to the launcher's canonical " + + "DataDirectory/pak/acdream.pak path."); + } + + return record with + { + Version = LauncherInstallRecord.CurrentRecordVersion, + DatDirectory = datValidation.Directory, + PreparedAssetPath = Path.GetFullPath(PreparedAssetPath), + }; + } + + private async Task VerifyFileAsync( + string path, + LauncherInstallRecord record, + CancellationToken cancellationToken) + { + if (!File.Exists(path)) + { + return new FileVerification(false, "The prepared package is missing."); + } + + try + { + long length = new FileInfo(path).Length; + if (length != record.PreparedAssetSize) + { + return new FileVerification( + false, + $"The prepared package size changed (expected " + + $"{record.PreparedAssetSize}, found {length})."); + } + + string sha256 = await _computeSha256(path, cancellationToken) + .ConfigureAwait(false); + return FileIntegrity.Matches(sha256, record.PreparedAssetSha256) + ? new FileVerification(true, "Client content verified.") + : new FileVerification( + false, + "The prepared package SHA-256 does not match the install record."); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return new FileVerification( + false, + $"The prepared package could not be verified: {ex.Message}"); + } + } + + private static InstallRecordVerification Verified( + LauncherInstallRecord record, + string status = "Client content SHA-256, size, and bake-tool version verified.") => + new(InstallRecordVerificationState.Verified, record, status); + + private static InstallRecordVerification Invalid(string status) => + new(InstallRecordVerificationState.Invalid, null, status); + + private static bool IsSha256(string value) => + value.Length == 64 && value.All(Uri.IsHexDigit); + + private static string FormatMissing(IReadOnlyList missing) => + missing.Count == 0 + ? string.Empty + : " Missing: " + string.Join(", ", missing) + "."; + + private static bool PathsEqual(string left, string right) => + string.Equals( + Path.TrimEndingDirectorySeparator(left), + Path.TrimEndingDirectorySeparator(right), + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal); + + private static bool CanonicalSpellingEquals( + string serialized, + string canonical, + bool trimEndingSeparator) + { + string candidate = trimEndingSeparator + ? Path.TrimEndingDirectorySeparator(serialized) + : serialized; + return string.Equals( + candidate, + canonical, + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal); + } + + internal static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch + { + // A stale temp/backup is never treated as a published record. The + // next startup verification retries cleanup/recovery. + } + } + + private sealed record FileVerification(bool IsValid, string Status); +} diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs new file mode 100644 index 00000000..f8de24a7 --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs @@ -0,0 +1,561 @@ +using AcDream.Launcher.Core.Integrity; +using AcDream.Launcher.Core.Launching; +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Installation; + +public enum LauncherInstallPhase +{ + Idle, + ValidatingDatFiles, + PreparingOutput, + BakingMeshes, + BakingCollision, + VerifyingPackage, + SavingRecord, + Completed, + Cancelled, + Failed, +} + +public sealed record LauncherInstallProgress( + LauncherInstallPhase Phase, + string Status, + long Completed = 0, + long Total = 0, + int Failures = 0, + double EtaSeconds = 0) +{ + public double Fraction => Total > 0 + ? Math.Clamp((double)Completed / Total, 0, 1) + : 0; +} + +public sealed record LauncherInstallResult(LauncherInstallRecord Record); + +public sealed class LauncherInstallException : Exception +{ + public LauncherInstallException(string message) + : base(message) + { + } + + public LauncherInstallException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +public interface ILauncherInstaller +{ + IReadOnlyList DetectDatDirectories(); + + DatDirectoryValidation ValidateDatDirectory(string? directory); + + Task LoadExistingAsync( + CancellationToken cancellationToken = default); + + Task InstallAsync( + string datDirectory, + int threads, + IProgress? progress = null, + CancellationToken cancellationToken = default); +} + +/// +/// 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. +/// +public sealed class LauncherInstaller : ILauncherInstaller +{ + private readonly string _bakeExecutablePath; + private readonly DatDirectoryLocator _datDirectories; + private readonly LauncherInstallRecordStore _recordStore; + private readonly IBakeProcessRunner _processRunner; + private readonly Func> _computeSha256; + private readonly SemaphoreSlim _installGate = new(1, 1); + + private LauncherInstallRecord? _verifiedRecord; + + public LauncherInstaller( + ApplicationPathSet paths, + string bakeExecutablePath, + DatDirectoryLocator? datDirectories = null, + LauncherInstallRecordStore? recordStore = null, + IBakeProcessRunner? processRunner = null, + Func>? computeSha256 = null) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentException.ThrowIfNullOrWhiteSpace(bakeExecutablePath); + _bakeExecutablePath = Path.GetFullPath(bakeExecutablePath); + _datDirectories = datDirectories ?? new DatDirectoryLocator(); + _computeSha256 = computeSha256 + ?? ((path, cancellationToken) => + FileIntegrity.ComputeSha256HexAsync(path, cancellationToken)); + _recordStore = recordStore + ?? new LauncherInstallRecordStore( + paths, + _datDirectories, + _computeSha256); + _processRunner = processRunner ?? new SystemBakeProcessRunner(); + } + + public IReadOnlyList DetectDatDirectories() => + _datDirectories.Detect(); + + public DatDirectoryValidation ValidateDatDirectory(string? directory) => + _datDirectories.Validate(directory); + + public async Task LoadExistingAsync( + CancellationToken cancellationToken = default) + { + await _installGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await using InstallerTransactionLease lease = + await InstallerTransactionLease.AcquireAsync( + _recordStore.DataDirectory, + cancellationToken) + .ConfigureAwait(false); + InstallRecordVerification verification = + await RecoverExistingUnderPublicationGuardAsync( + cancellationToken) + .ConfigureAwait(false); + _verifiedRecord = verification.Record; + return verification; + } + finally + { + _installGate.Release(); + } + } + + public async Task InstallAsync( + string datDirectory, + int threads, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + if (threads <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(threads), + "Bake thread count must be positive."); + } + + await _installGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await using InstallerTransactionLease lease = + await InstallerTransactionLease.AcquireAsync( + _recordStore.DataDirectory, + cancellationToken) + .ConfigureAwait(false); + return await InstallCoreAsync( + datDirectory, + threads, + progress, + cancellationToken) + .ConfigureAwait(false); + } + finally + { + _installGate.Release(); + } + } + + private async Task InstallCoreAsync( + string datDirectory, + int threads, + IProgress? progress, + CancellationToken cancellationToken) + { + Report( + progress, + LauncherInstallPhase.ValidatingDatFiles, + "Validating the four retail DAT files..."); + DatDirectoryValidation validation = _datDirectories.Validate(datDirectory); + if (!validation.IsValid) + { + string message = validation.Message + + FormatMissing(validation.MissingFileNames); + Report(progress, LauncherInstallPhase.Failed, message); + throw new LauncherInstallException(message); + } + + if (!File.Exists(_bakeExecutablePath)) + { + string message = + $"The co-deployed bake tool is missing at '{_bakeExecutablePath}'."; + Report(progress, LauncherInstallPhase.Failed, message); + throw new LauncherInstallException(message); + } + + string outputPath = _recordStore.PreparedAssetPath; + string backupPath = LauncherInstallRecordStore.GetBackupPath(outputPath); + InstallRecordVerification existing = + await RecoverExistingUnderPublicationGuardAsync(cancellationToken) + .ConfigureAwait(false); + _verifiedRecord = existing.Record; + + Directory.CreateDirectory( + Path.GetDirectoryName(outputPath) + ?? throw new InvalidOperationException( + "The prepared package path has no parent directory.")); + + Report( + progress, + LauncherInstallPhase.PreparingOutput, + "Preparing the atomic package transaction..."); + bool previousPreserved = PreservePreviousPackage(outputPath, backupPath); + if (!previousPreserved) + { + LauncherInstallRecordStore.TryDelete(backupPath); + } + + var parser = new BakeProgressJsonlParser(); + var protocol = new BakeProgressProtocol(); + string? publicationNonce = null; + + void Observe(BakeProgressEvent progressEvent) + { + bool accepted = protocol.Observe(progressEvent); + switch (progressEvent) + { + case BakeWorkProgressEvent value when accepted: + LauncherInstallPhase phase = value.Phase switch + { + "mesh" => LauncherInstallPhase.BakingMeshes, + "collision" => LauncherInstallPhase.BakingCollision, + _ => LauncherInstallPhase.BakingMeshes, + }; + Report( + progress, + phase, + $"Baking {value.Phase} assets: " + + $"{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, + $"Bake tool error: {value.Message}"); + break; + case MalformedBakeProgressEvent value: + Report( + progress, + LauncherInstallPhase.Failed, + $"Malformed bake progress: {value.Reason}"); + break; + // Human lines are deliberately ignored, and unknown event + // kinds are forward-compatible. A future protocol version + // cannot satisfy the required v1 started/completed pair. + } + } + + try + { + cancellationToken.ThrowIfCancellationRequested(); + publicationNonce = BakePublicationGuardPaths.CreateNonce(); + await using ( + BakePublicationGuardContract.PublicationLease publication = + await BakePublicationGuardContract.AcquireAsync( + outputPath, + cancellationToken) + .ConfigureAwait(false)) + { + BakePublicationGuardContract.Authorize( + outputPath, + publicationNonce, + publication); + } + + var request = new BakeProcessRequest( + _bakeExecutablePath, + validation.Directory, + outputPath, + threads, + publicationNonce); + BakeProcessResult processResult = await _processRunner.RunAsync( + request, + chunk => + { + foreach (BakeProgressEvent progressEvent in parser.Append(chunk)) + { + Observe(progressEvent); + } + }, + cancellationToken) + .ConfigureAwait(false); + + foreach (BakeProgressEvent progressEvent in parser.Complete()) + { + Observe(progressEvent); + } + 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( + $"The bake tool reported an error: {protocol.Error.Message}"); + } + + BakeStartedEvent? started = protocol.Started; + BakeCompletedEvent? completed = protocol.Completed; + if (started is null || completed is null) + { + throw new LauncherInstallException( + "The bake protocol did not finish with a v1 completed event."); + } + + if (started.BakeToolVersion != completed.BakeToolVersion + || completed.BakeToolVersion + != LauncherInstallRecordStore.CurrentBakeToolVersion) + { + throw new LauncherInstallException( + $"The bake tool reported version {completed.BakeToolVersion}; " + + $"version {LauncherInstallRecordStore.CurrentBakeToolVersion} " + + "is required."); + } + + if (completed.Failures != 0) + { + throw new LauncherInstallException( + $"The bake completed with {completed.Failures:N0} failed assets."); + } + + if (!File.Exists(outputPath)) + { + throw new LauncherInstallException( + "The bake tool reported success but did not publish acdream.pak."); + } + + long size = new FileInfo(outputPath).Length; + if (size <= 0 || size != completed.OutputBytes) + { + throw new LauncherInstallException( + "The published package size does not match the bake completion record."); + } + + Report( + progress, + LauncherInstallPhase.VerifyingPackage, + "Computing the prepared package SHA-256..."); + string sha256 = await _computeSha256(outputPath, cancellationToken) + .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + var record = new LauncherInstallRecord( + validation.Directory, + outputPath, + sha256, + size, + completed.BakeToolVersion); + Report( + progress, + LauncherInstallPhase.SavingRecord, + "Saving the verified install record..."); + await _recordStore.SaveAtomicallyUnderLeaseAsync( + record, + cancellationToken) + .ConfigureAwait(false); + + _verifiedRecord = record; + await FinalizeSuccessfulPublicationAsync( + outputPath, + backupPath, + publicationNonce) + .ConfigureAwait(false); + Report( + progress, + LauncherInstallPhase.Completed, + "Client content installed and verified.", + completed: 1, + total: 1); + return new LauncherInstallResult(record); + } + catch (OperationCanceledException) + { + await FinalizeFailedPublicationAsync( + outputPath, + backupPath, + previousPreserved, + publicationNonce) + .ConfigureAwait(false); + Report( + progress, + LauncherInstallPhase.Cancelled, + "Installation cancelled; no new install record was published."); + throw; + } + catch (Exception ex) + { + await FinalizeFailedPublicationAsync( + outputPath, + backupPath, + previousPreserved, + publicationNonce) + .ConfigureAwait(false); + Report( + progress, + LauncherInstallPhase.Failed, + $"Installation failed: {ex.Message}"); + if (ex is LauncherInstallException) + { + throw; + } + + throw new LauncherInstallException("Installation failed.", ex); + } + } + + private async Task + RecoverExistingUnderPublicationGuardAsync( + CancellationToken cancellationToken) + { + string outputPath = _recordStore.PreparedAssetPath; + await using BakePublicationGuardContract.PublicationLease publication = + await BakePublicationGuardContract.AcquireAsync( + outputPath, + cancellationToken) + .ConfigureAwait(false); + // Any child whose parent died before it acquired this lock is now + // irrevocably stale. A child already holding the lock must finish its + // promotion before recovery reaches this invalidation point. + BakePublicationGuardContract.Invalidate(outputPath, publication); + BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath); + return await _recordStore.LoadAndVerifyUnderLeaseAsync(cancellationToken) + .ConfigureAwait(false); + } + + private static async Task FinalizeSuccessfulPublicationAsync( + string outputPath, + string backupPath, + string publicationNonce) + { + await using BakePublicationGuardContract.PublicationLease publication = + await BakePublicationGuardContract.AcquireAsync( + outputPath, + CancellationToken.None) + .ConfigureAwait(false); + BakePublicationGuardContract.Invalidate( + outputPath, + publication, + publicationNonce); + LauncherInstallRecordStore.TryDelete(backupPath); + BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath); + } + + private static async Task FinalizeFailedPublicationAsync( + string outputPath, + string backupPath, + bool previousPreserved, + string? publicationNonce) + { + await using BakePublicationGuardContract.PublicationLease publication = + await BakePublicationGuardContract.AcquireAsync( + outputPath, + CancellationToken.None) + .ConfigureAwait(false); + BakePublicationGuardContract.Invalidate( + outputPath, + publication, + publicationNonce); + RestorePreviousPackage(outputPath, backupPath, previousPreserved); + BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath); + } + + private bool PreservePreviousPackage(string outputPath, string backupPath) + { + LauncherInstallRecord? previous = _verifiedRecord; + if (previous is null + || !PathsEqual(previous.PreparedAssetPath, outputPath) + || !File.Exists(outputPath)) + { + return false; + } + + File.Move(outputPath, backupPath, overwrite: true); + 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); + } + + private static string BuildChildFailure( + int exitCode, + string? jsonError, + string standardError) + { + string detail = !string.IsNullOrWhiteSpace(jsonError) + ? jsonError + : standardError.Trim(); + return detail.Length == 0 + ? $"The bake tool exited with code {exitCode}." + : $"The bake tool exited with code {exitCode}: {detail}"; + } + + private static void Report( + IProgress? progress, + LauncherInstallPhase phase, + string status, + long completed = 0, + long total = 0, + int failures = 0, + double etaSeconds = 0) => + progress?.Report(new LauncherInstallProgress( + phase, + status, + completed, + total, + failures, + etaSeconds)); + + private static string FormatMissing(IReadOnlyList missing) => + missing.Count == 0 + ? string.Empty + : " Missing: " + string.Join(", ", missing) + "."; + + private static bool PathsEqual(string left, string right) => + string.Equals( + Path.GetFullPath(left), + Path.GetFullPath(right), + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal); +} diff --git a/src/AcDream.Launcher.Core/Integrity/FileIntegrity.cs b/src/AcDream.Launcher.Core/Integrity/FileIntegrity.cs new file mode 100644 index 00000000..dab09b90 --- /dev/null +++ b/src/AcDream.Launcher.Core/Integrity/FileIntegrity.cs @@ -0,0 +1,66 @@ +using System.Security.Cryptography; + +namespace AcDream.Launcher.Core.Integrity; + +/// +/// Streaming SHA-256 for pak/download verification, consumed by the +/// install engine (LA9) and the updater (LA10). Kept minimal in this +/// slice: hash a file and compare its hex digest. +/// +public static class FileIntegrity +{ + /// + /// Computes the lower-case hex SHA-256 digest of a file, streaming it + /// from disk rather than loading it fully into memory (relevant for + /// the ~30 GB pak file LA9 verifies). + /// + public static string ComputeSha256Hex(string filePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + + using FileStream stream = new( + filePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read); + byte[] hash = SHA256.HashData(stream); + return Convert.ToHexStringLower(hash); + } + + /// + /// Asynchronous, cancellable counterpart used while verifying a multi- + /// gigabyte prepared package. The file stays streamed and no buffer is + /// retained after the hash completes. + /// + public static async Task ComputeSha256HexAsync( + string filePath, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + + await using FileStream stream = new( + filePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 1024 * 1024, + options: FileOptions.Asynchronous | FileOptions.SequentialScan); + byte[] hash = await SHA256.HashDataAsync(stream, cancellationToken) + .ConfigureAwait(false); + return Convert.ToHexStringLower(hash); + } + + /// Case-insensitive hex comparison — callers may receive an + /// expected digest in either case from a manifest or a hand-typed + /// fixture. + public static bool Matches(string actualHex, string expectedHex) + { + ArgumentNullException.ThrowIfNull(actualHex); + ArgumentNullException.ThrowIfNull(expectedHex); + return string.Equals(actualHex, expectedHex, StringComparison.OrdinalIgnoreCase); + } + + /// Computes and compares in one call. + public static bool Verify(string filePath, string expectedHex) => + Matches(ComputeSha256Hex(filePath), expectedHex); +} diff --git a/src/AcDream.Launcher.Core/Launching/BoundedProcessOutputCapture.cs b/src/AcDream.Launcher.Core/Launching/BoundedProcessOutputCapture.cs new file mode 100644 index 00000000..009a39d9 --- /dev/null +++ b/src/AcDream.Launcher.Core/Launching/BoundedProcessOutputCapture.cs @@ -0,0 +1,253 @@ +using System.Text; + +namespace AcDream.Launcher.Core.Launching; + +/// +/// Captures a supervised child's stderr into a per-session file, bounded +/// so a log-spamming (or endlessly crash-looping) child can never fill the +/// disk (fix #406 sibling gap). Before this class existed the launcher +/// discarded a child's stdout/stderr entirely — including the unhandled- +/// exception stack trace a crash writes there — so diagnosing exactly the +/// #406 crash required re-running the identical binary + session config +/// from a console by hand. This is purely additive diagnostics: it does +/// not touch the pinned status.jsonl event vocabulary (Campaign LA +/// plan §LA1) at all. +/// +/// +/// Every write opens the file fresh (), +/// writes its chunk, flushes, and closes — mirroring +/// 's "no long- +/// lived file handle" posture exactly, and for the SAME reason: a +/// long-lived write handle only opened with +/// is NOT actually concurrently readable in practice — Windows' sharing +/// check is bidirectional, and a plain File.ReadAllText-style +/// reader (which itself only requests , not +/// ) fails with a sharing violation +/// against ANY still-open handle that holds write access, regardless of +/// what share flags that writer declared. Opening fresh per write avoids +/// the problem entirely: there is never a handle open except for the +/// duration of one small, synchronous write. +/// +/// +/// +/// Every write is defensively guarded the same way +/// guards its own +/// I/O: a recoverable failure latches this sink into a permanent no-op +/// rather than throwing back into the caller's read-and-forward loop. The +/// launcher's job is to supervise the child, not to go down because a +/// local diagnostics file could not be written. +/// +/// +/// +/// Callers are responsible for continuing to drain the child's stderr +/// stream/pipe even after this sink stops accepting bytes (cap reached or +/// latched off) — this class only bounds what lands on disk, never how +/// much the caller may read. A caller that stopped draining on a full +/// sink could leave the child blocked writing to a full OS pipe buffer. +/// +/// +public sealed class BoundedProcessOutputCapture : IDisposable +{ + /// 2 MiB is generous for the lifecycle/shutdown diagnostics + /// and a crash stack trace this exists to capture, while still being a + /// firm, small bound against a pathological child that spams stderr + /// for an entire long-running headless-bot session. + public const long DefaultMaxBytes = 2 * 1024 * 1024; + + private static readonly byte[] Newline = "\n"u8.ToArray(); + + private readonly string _path; + private readonly long _maxBytes; + private readonly object _gate = new(); + private bool _directoryEnsured; + private long _written; + private bool _capped; + private bool _latchedOff; + private bool _disposed; + + public BoundedProcessOutputCapture(string path, long maxBytes = DefaultMaxBytes) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + if (maxBytes <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(maxBytes), + "The bounded capture size must be positive."); + } + + _path = Path.GetFullPath(path); + _maxBytes = maxBytes; + } + + /// True once no further byte will ever be written — either + /// the size cap was reached (a truncation marker was appended) or a + /// local I/O failure latched this sink off. Exposed for tests; a + /// caller never needs to check this before calling + /// — it is always safe to call. + public bool IsDone + { + get + { + lock (_gate) + { + return _capped || _latchedOff || _disposed; + } + } + } + + /// Appends one line of already-decoded text (e.g. one + /// Process.ErrorDataReceived line) followed by a newline. Never + /// throws. A line (the sentinel .NET's + /// ErrorDataReceived raises once when the stream closes) is a + /// silent no-op. + /// + /// + /// F7 (Campaign CC gate round 1 closeout): the text and its trailing + /// newline are combined into ONE buffer and written through ONE + /// call. The class doc's own "every write + /// opens the file fresh" contract means two separate calls (text, then + /// newline) used to open/write/flush/close the file TWICE per logical + /// line — needless I/O for a sink that already fires once per received + /// output line. + /// + /// + public void AppendLine(string? line) + { + if (line is null) + { + return; + } + + byte[] textBytes = Encoding.UTF8.GetBytes(line); + var buffer = new byte[textBytes.Length + Newline.Length]; + textBytes.CopyTo(buffer, 0); + Newline.CopyTo(buffer, textBytes.Length); + + lock (_gate) + { + AppendLocked(buffer); + } + } + + /// Appends a raw decoded chunk (no implied line boundary). + /// Never throws. + public void Append(ReadOnlySpan data) + { + if (data.IsEmpty) + { + return; + } + + lock (_gate) + { + AppendLocked(data); + } + } + + private void AppendLocked(ReadOnlySpan data) + { + if (data.IsEmpty || _disposed || _latchedOff || _capped) + { + return; + } + + try + { + long remaining = _maxBytes - _written; + if (remaining <= 0) + { + CapLocked(); + return; + } + + int toWrite = data.Length > remaining + ? checked((int)remaining) + : data.Length; + WriteChunkLocked(data[..toWrite]); + _written += toWrite; + + if (toWrite < data.Length) + { + CapLocked(); + } + } + catch (Exception error) when (IsRecoverableIoFailure(error)) + { + _latchedOff = true; + } + } + + /// Opens the file fresh, writes one chunk, flushes, and + /// closes — see the class doc for why this never keeps a long-lived + /// handle. Exceptions propagate to the caller's own guard. + private void WriteChunkLocked(ReadOnlySpan chunk) + { + EnsureDirectoryLocked(); + using FileStream stream = new( + _path, + FileMode.Append, + FileAccess.Write, + FileShare.Read); + stream.Write(chunk); + stream.Flush(); + } + + private void EnsureDirectoryLocked() + { + if (_directoryEnsured) + { + return; + } + + string? directory = Path.GetDirectoryName(_path); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + _directoryEnsured = true; + } + + /// Writes the one-time truncation marker — every later + /// / becomes a cheap + /// no-op via . + private void CapLocked() + { + if (_capped) + { + return; + } + + _capped = true; + try + { + byte[] marker = Encoding.UTF8.GetBytes( + $"\n[acdream-launcher] client.err.log truncated at {_maxBytes} bytes\n"); + WriteChunkLocked(marker); + } + catch (Exception error) when (IsRecoverableIoFailure(error)) + { + // The marker itself is best-effort — the cap already took + // effect via _capped regardless of whether it could be written. + } + } + + private static bool IsRecoverableIoFailure(Exception error) => + error is IOException + or UnauthorizedAccessException + or NotSupportedException + or System.Security.SecurityException + or DirectoryNotFoundException; + + /// No open handle to release — see the class doc. Marks this + /// sink permanently done so any late-arriving chunk from a caller's + /// still-draining pump is a silent no-op instead of reopening the + /// file after the caller considers capture finished. + public void Dispose() + { + lock (_gate) + { + _disposed = true; + } + } +} diff --git a/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs b/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs new file mode 100644 index 00000000..b3f9a3ce --- /dev/null +++ b/src/AcDream.Launcher.Core/Launching/ILauncherChildProcess.cs @@ -0,0 +1,209 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace AcDream.Launcher.Core.Launching; + +/// +/// Thin seam over so +/// 's lifecycle and Stop +/// (CloseMainWindow, falling back to Kill after a timeout) state machine +/// can be unit-tested against an in-memory fake without spawning a real +/// OS process or depending on real window-message timing — both +/// "injectable for tests" per Campaign LA spec §3. +/// +public interface ILauncherChildProcess : IDisposable +{ + bool HasExited { get; } + + int ExitCode { get; } + + /// The child's redirected stdin. The supervisor writes the + /// account password here (followed by a newline) and then closes it — + /// never anywhere else. + TextWriter StandardInput { get; } + + /// Fires exactly once, when the child process terminates + /// (mirrors with + /// EnableRaisingEvents on). + event EventHandler? Exited; + + void Start(); + + /// + /// Attempts a graceful stop signal appropriate to the platform, + /// tried BEFORE (Campaign LA plan §LA3 + /// review finding F3): a no-window console host (e.g. + /// AcDream.Headless) never has a main window for + /// to close, so without this step + /// always degraded + /// straight to a timeout + hard — and a hard kill + /// leaves the ACE account session stuck for several minutes (a + /// documented project landmine; see CLAUDE.md + /// "Logout-before-reconnect"). On Linux this sends SIGINT (K4 proved + /// the headless host's SIGINT handler produces an ACE-confirmed + /// graceful logout). On Windows, console-capable children are started + /// as distinct process-group leaders and receive a targeted + /// CTRL_BREAK_EVENT. Returns true only when the signal was actually + /// delivered; never throws. + /// + bool TryRequestGracefulStop(); + + /// Mirrors — requests + /// a graceful close via WM_CLOSE. Returns false for a console/no- + /// window process (never throws), matching the real API. + bool CloseMainWindow(); + + /// Mirrors with + /// entireProcessTree: true. + void Kill(); + + bool WaitForExit(TimeSpan timeout); +} + +/// Creates instances from a +/// . +public interface ILauncherChildProcessFactory +{ + ILauncherChildProcess Create(LauncherProcessSpec spec); +} + +/// Real-process implementation used in production. +public sealed class SystemChildProcessFactory : ILauncherChildProcessFactory +{ + public ILauncherChildProcess Create(LauncherProcessSpec spec) => + OperatingSystem.IsWindows() && spec.SupportsConsoleGracefulStop + ? new WindowsSystemChildProcess(spec) + : new SystemChildProcess(spec); +} + +internal sealed partial class SystemChildProcess : ILauncherChildProcess +{ + // SIGINT's numeric value (POSIX-stable across Linux distributions). + // K4/Slice K already proved the headless host's SIGINT handler + // produces an ACE-confirmed graceful logout. + private const int Sigint = 2; + + [LibraryImport("libc", SetLastError = true)] + private static partial int kill(int pid, int sig); + + private readonly Process _process; + private readonly bool _supportsConsoleGracefulStop; + private readonly BoundedProcessOutputCapture? _stderrCapture; + private bool _raisingEnabled; + private bool _errorReadingEnabled; + + internal SystemChildProcess(LauncherProcessSpec spec) + { + ArgumentNullException.ThrowIfNull(spec); + _supportsConsoleGracefulStop = spec.SupportsConsoleGracefulStop; + + var startInfo = new ProcessStartInfo + { + FileName = spec.ExecutablePath, + RedirectStandardInput = true, + UseShellExecute = false, + }; + + // fix #406 sibling gap: capture stderr (crash stack traces land + // there) into a bounded per-session file instead of discarding it. + // Purely additive — RedirectStandardOutput/CreateNoWindow are left + // untouched, and a spec with no StderrLogPath behaves exactly as + // before. + if (!string.IsNullOrWhiteSpace(spec.StderrLogPath)) + { + startInfo.RedirectStandardError = true; + _stderrCapture = new BoundedProcessOutputCapture(spec.StderrLogPath); + } + + foreach (string argument in spec.Arguments) + { + startInfo.ArgumentList.Add(argument); + } + + if (!string.IsNullOrEmpty(spec.WorkingDirectory)) + { + startInfo.WorkingDirectory = spec.WorkingDirectory; + } + + _process = new Process { StartInfo = startInfo }; + } + + public bool HasExited => _process.HasExited; + + public int ExitCode => _process.ExitCode; + + public TextWriter StandardInput => _process.StandardInput; + + public event EventHandler? Exited; + + public void Start() + { + _process.EnableRaisingEvents = true; + _process.Exited += OnExited; + _raisingEnabled = true; + if (_stderrCapture is not null) + { + _process.ErrorDataReceived += OnErrorDataReceived; + _errorReadingEnabled = true; + } + + _process.Start(); + if (_errorReadingEnabled) + { + _process.BeginErrorReadLine(); + } + } + + public bool TryRequestGracefulStop() + { + if (!OperatingSystem.IsLinux() || !_supportsConsoleGracefulStop) + { + // Windows console-capable children use + // WindowsSystemChildProcess. Graphical/non-console children + // deliberately retain the Process/WM_CLOSE path. + return false; + } + + try + { + return kill(_process.Id, Sigint) == 0; + } + catch + { + // Matches CloseMainWindow's "never throws" contract — the + // process may not have started yet, may have already exited + // (ESRCH), or the platform may lack libc under an unusual + // Linux runtime; any of these degrade to "signal not sent" + // rather than an exception out of Stop(). + return false; + } + } + + public bool CloseMainWindow() => _process.CloseMainWindow(); + + public void Kill() => _process.Kill(entireProcessTree: true); + + public bool WaitForExit(TimeSpan timeout) => _process.WaitForExit(timeout); + + public void Dispose() + { + if (_raisingEnabled) + { + _process.Exited -= OnExited; + } + + if (_errorReadingEnabled) + { + _process.ErrorDataReceived -= OnErrorDataReceived; + } + + _process.Dispose(); + _stderrCapture?.Dispose(); + } + + private void OnExited(object? sender, EventArgs e) => + Exited?.Invoke(this, EventArgs.Empty); + + private void OnErrorDataReceived(object? sender, DataReceivedEventArgs e) => + _stderrCapture?.AppendLine(e.Data); +} diff --git a/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs b/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs new file mode 100644 index 00000000..c89c279a --- /dev/null +++ b/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs @@ -0,0 +1,26 @@ +namespace AcDream.Launcher.Core.Launching; + +/// +/// The DAT/pak locations a completed install (LA9) records and every +/// session-config composition consumes for +/// . Integrity metadata is launcher- +/// local: it is verified before this record is admitted to the orchestrator +/// and is deliberately not copied into the host session-config contract. +/// +public sealed record LauncherInstallRecord( + string DatDirectory, + string PreparedAssetPath, + string PreparedAssetSha256 = "", + long PreparedAssetSize = 0, + uint BakeToolVersion = 0) +{ + public const int CurrentRecordVersion = 1; + + public int Version { get; init; } = CurrentRecordVersion; + + public bool HasIntegrityMetadata => + !string.IsNullOrEmpty(PreparedAssetSha256) + && PreparedAssetSha256.Length == 64 + && PreparedAssetSize > 0 + && BakeToolVersion > 0; +} diff --git a/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs b/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs new file mode 100644 index 00000000..9cdac21b --- /dev/null +++ b/src/AcDream.Launcher.Core/Launching/LauncherProcessSpec.cs @@ -0,0 +1,24 @@ +namespace AcDream.Launcher.Core.Launching; + +/// +/// What to spawn: the host executable path + argument list (both +/// injectable per Campaign LA spec §3, e.g. AcDream.Headless --config +/// <path> or AcDream.App --session-config <path>). +/// Deliberately carries no credential field — the password is a separate +/// transient parameter to +/// that flows only to the child's stdin, never into this spec, an +/// argument list, or a process environment. Console-capable specs set +/// so Windows starts them +/// as isolated process-group leaders for targeted CTRL_BREAK_EVENT and +/// Linux sends SIGINT; graphical specs leave it false and use WM_CLOSE. +/// is an optional, purely-additive +/// diagnostics sink (fix #406 sibling gap): when set, the launcher +/// captures the child's stderr into a bounded file at that path instead +/// of discarding it; when null, behavior is exactly as before. +/// +public sealed record LauncherProcessSpec( + string ExecutablePath, + IReadOnlyList Arguments, + string? WorkingDirectory = null, + bool SupportsConsoleGracefulStop = true, + string? StderrLogPath = null); diff --git a/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs new file mode 100644 index 00000000..252847c5 --- /dev/null +++ b/src/AcDream.Launcher.Core/Launching/LauncherProcessSupervisor.cs @@ -0,0 +1,345 @@ +using System.Runtime.ExceptionServices; + +namespace AcDream.Launcher.Core.Launching; + +/// +/// Test seam for one supervised launcher child. The Avalonia orchestration +/// layer owns this interface through a factory and never constructs or drives +/// directly. +/// +public interface ILauncherProcessSupervisor : IDisposable +{ + LauncherSessionState State { get; } + + int? ExitCode { get; } + + event EventHandler? StateChanged; + + void Start(LauncherProcessSpec spec, string? password); + + void Stop(TimeSpan timeout); +} + +public interface ILauncherProcessSupervisorFactory +{ + ILauncherProcessSupervisor Create(); +} + +public sealed class LauncherProcessSupervisorFactory( + ILauncherChildProcessFactory? childProcessFactory = null) + : ILauncherProcessSupervisorFactory +{ + private readonly ILauncherChildProcessFactory _childProcessFactory = + childProcessFactory ?? new SystemChildProcessFactory(); + + public ILauncherProcessSupervisor Create() => + new LauncherProcessSupervisor(_childProcessFactory); +} + +/// +/// Spawns a host process (App/Headless), feeds the account password to +/// its stdin then closes it, and supervises its lifetime (Campaign LA +/// spec §3/§6). One supervisor instance owns exactly one child process +/// for its lifetime — start a new supervisor per launched session. +/// +public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor +{ + private static readonly TimeSpan DisposeStopTimeout = TimeSpan.FromSeconds(5); + private readonly ILauncherChildProcessFactory _factory; + private readonly object _gate = new(); + private readonly Queue _pendingStateChanges = []; + private ILauncherChildProcess? _process; + private LauncherSessionState _state = LauncherSessionState.Starting; + private int? _exitCode; + private bool _publishingStateChanges; + private bool _disposed; + + public LauncherProcessSupervisor(ILauncherChildProcessFactory? factory = null) + { + _factory = factory ?? new SystemChildProcessFactory(); + } + + public LauncherSessionState State + { + get + { + lock (_gate) + { + return _state; + } + } + } + + /// Set once reaches + /// ; null before then. + /// + public int? ExitCode + { + get + { + lock (_gate) + { + return _exitCode; + } + } + } + + /// Fires on every + /// transition, in order. + public event EventHandler? StateChanged; + + /// + /// Spawns the child described by , writes + /// (if any) followed by a newline to its + /// stdin, then closes stdin. The password is never written anywhere + /// else — not into , not into an environment + /// variable, not logged. + /// + public void Start(LauncherProcessSpec spec, string? password) + { + ArgumentNullException.ThrowIfNull(spec); + + ILauncherChildProcess process; + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_process is not null) + { + throw new InvalidOperationException( + "This supervisor already owns a process; start a new " + + "supervisor per launched session."); + } + + process = _factory.Create(spec); + process.Exited += OnProcessExited; + _process = process; + } + + SetState(LauncherSessionState.Starting); + + bool started = false; + try + { + process.Start(); + started = true; + + if (password is not null) + { + process.StandardInput.Write(password); + process.StandardInput.Write('\n'); + process.StandardInput.Flush(); + } + + process.StandardInput.Close(); + } + catch + { + lock (_gate) + { + process.Exited -= OnProcessExited; + _process = null; + } + + // A failure after the child actually started (e.g. the stdin + // pipe breaks while feeding the password) must not leave a + // live, unsupervised, undisposable child running (Campaign LA + // plan §LA3 review finding F8) — kill the whole process tree + // and release the handle before propagating the original + // failure. + if (started) + { + try + { + process.Kill(); + } + catch + { + // Best-effort — the ORIGINAL failure, rethrown below, + // is what the caller needs to see; a failed cleanup + // kill must not replace it. + } + } + + process.Dispose(); + + throw; + } + + SetState(LauncherSessionState.Running); + } + + /// + /// Requests a graceful stop — first + /// (SIGINT + /// on Linux; targeted CTRL_BREAK_EVENT for supported Windows console + /// children), + /// then — falling + /// back to if the process has + /// not exited within . A no-op if + /// was never called or the process has already + /// exited. + /// + /// BLOCKS THE CALLING THREAD for up to + /// (via the real child's WaitForExit) — callers on a UI thread + /// must dispatch this off-thread rather than calling it directly (a + /// binding requirement for the LA4 Avalonia UI, which will call this + /// method from a "stop session" action). + /// + /// + public void Stop(TimeSpan timeout) + { + ILauncherChildProcess? process; + lock (_gate) + { + process = _process; + } + + if (process is null || process.HasExited) + { + return; + } + + process.TryRequestGracefulStop(); + process.CloseMainWindow(); + if (!process.WaitForExit(timeout) && !process.HasExited) + { + process.Kill(); + if (!process.WaitForExit(Timeout.InfiniteTimeSpan) && !process.HasExited) + { + throw new InvalidOperationException( + "The launcher child could not be observed terminal after it was killed."); + } + } + } + + private void OnProcessExited(object? sender, EventArgs e) + { + int? exitCode; + lock (_gate) + { + exitCode = _process is { HasExited: true } process + ? process.ExitCode + : null; + } + + SetState(LauncherSessionState.Exited, exitCode); + } + + /// + /// Applies a state transition, or silently ignores it (Campaign LA + /// plan §LA3 review finding F9): once reaches the + /// terminal , no later call + /// may move it anywhere else, and only + /// fires for a transition that was actually applied. This matters + /// because 's trailing + /// SetState(LauncherSessionState.Running) can race a + /// synchronous callback fired from + /// inside itself (a child that dies immediately) + /// — without this guard, "Running" would silently resurrect a + /// process that has already reported its exit. + /// + private void SetState(LauncherSessionState state, int? exitCode = null) + { + bool publish; + lock (_gate) + { + if (_state == LauncherSessionState.Exited) + { + return; + } + + _state = state; + if (state == LauncherSessionState.Exited) + { + _exitCode = exitCode; + } + + _pendingStateChanges.Enqueue(state); + publish = !_publishingStateChanges; + if (publish) + { + _publishingStateChanges = true; + } + } + + if (publish) + { + PublishPendingStateChanges(); + } + } + + /// + /// Drains state notifications through one publisher. Transition storage + /// stays under , but user callbacks run outside it so + /// they may re-enter the supervisor or wait for another thread reading + /// state without deadlocking. A concurrent/re-entrant transition queues + /// behind the notification already in flight, preserving storage order in + /// the externally observed event stream. + /// + private void PublishPendingStateChanges() + { + Exception? firstException = null; + while (true) + { + LauncherSessionState state; + lock (_gate) + { + if (_pendingStateChanges.Count == 0) + { + _publishingStateChanges = false; + break; + } + + state = _pendingStateChanges.Dequeue(); + } + + try + { + StateChanged?.Invoke(this, state); + } + catch (Exception ex) + { + // Preserve the previous propagation behavior, but finish + // publishing any transition already committed concurrently + // (especially terminal Exited) before rethrowing the first + // observer failure to the initiating caller. + firstException ??= ex; + } + } + + if (firstException is not null) + { + ExceptionDispatchInfo.Capture(firstException).Throw(); + } + } + + public void Dispose() + { + ILauncherChildProcess? process; + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + process = _process; + } + + if (process is { HasExited: false }) + { + Stop(DisposeStopTimeout); + } + + lock (_gate) + { + if (_process is not null) + { + _process.Exited -= OnProcessExited; + _process.Dispose(); + _process = null; + } + } + } +} diff --git a/src/AcDream.Launcher.Core/Launching/LauncherSessionState.cs b/src/AcDream.Launcher.Core/Launching/LauncherSessionState.cs new file mode 100644 index 00000000..2f04fae0 --- /dev/null +++ b/src/AcDream.Launcher.Core/Launching/LauncherSessionState.cs @@ -0,0 +1,21 @@ +namespace AcDream.Launcher.Core.Launching; + +/// Lifecycle of a launched host process, per Campaign LA spec §3 +/// ("supervise lifetime ... surface typed session state"). +public enum LauncherSessionState +{ + /// The child process has been created and the credential + /// handed off, but has not yet reached . + Starting, + + /// The child process is spawned and its stdin has been + /// closed. Says nothing about game-level connection state — that + /// comes from the status stream (see + /// AcDream.Launcher.Core.Status). + Running, + + /// The child process has exited. See + /// for the exit + /// code. + Exited, +} diff --git a/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs b/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs new file mode 100644 index 00000000..7d9eb594 --- /dev/null +++ b/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs @@ -0,0 +1,332 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using AcDream.Launcher.Core.Profiles; +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Launching; + +/// The composed session-config document plus the per-launch +/// paths derived from the session id, per Campaign LA spec §6. +/// is launcher-internal (fix #406 sibling +/// gap) — it never appears in the written session.json, only in +/// the the launcher spawns the +/// child with. +public sealed record ComposedSessionConfig( + string SessionId, + string ConfigFilePath, + string StatusFilePath, + string StderrLogPath, + SessionConfigDocument Document); + +/// +/// Injectable composition/write seam used by the canonical launcher +/// orchestrator. Production delegates to ; +/// tests can capture the exact request without writing a file or starting a +/// client process. +/// +public interface ILauncherSessionConfigService +{ + ComposedSessionConfig ComposeAndWrite( + ServerProfile server, + AccountProfile account, + CharacterProfile character, + LauncherInstallRecord install, + ApplicationPathSet paths, + string sessionId, + int? loginCommandDelayMs = null); + + ComposedSessionConfig ComposeProbeAndWrite( + ServerProfile server, + AccountProfile account, + LauncherInstallRecord install, + ApplicationPathSet paths, + string sessionId); +} + +public sealed class LauncherSessionConfigService : ILauncherSessionConfigService +{ + public ComposedSessionConfig ComposeAndWrite( + ServerProfile server, + AccountProfile account, + CharacterProfile character, + LauncherInstallRecord install, + ApplicationPathSet paths, + string sessionId, + int? loginCommandDelayMs = null) => + SessionConfigComposer.ComposeAndWrite( + server, + account, + character, + install, + paths, + sessionId, + loginCommandDelayMs); + + public ComposedSessionConfig ComposeProbeAndWrite( + ServerProfile server, + AccountProfile account, + LauncherInstallRecord install, + ApplicationPathSet paths, + string sessionId) => + SessionConfigComposer.ComposeProbeAndWrite( + server, + account, + install, + paths, + sessionId); +} + +/// +/// Builds the per-launch from a +/// profile character + install record (Campaign LA spec §6). Passwords +/// NEVER appear in the composed document — the credential is always the +/// standardInput provider; the launcher feeds the password to the +/// child process's stdin separately (). +/// +public static class SessionConfigComposer +{ + internal static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true, + }; + + /// + /// Builds the document and the paths it would be written to under + /// <CacheDirectory>/launcher/sessions/<sessionId>/, + /// without touching disk. is caller- + /// supplied so composition stays a pure function of its inputs + /// (golden-file tests pass a fixed id). + /// + public static ComposedSessionConfig Compose( + ServerProfile server, + AccountProfile account, + CharacterProfile character, + LauncherInstallRecord install, + ApplicationPathSet paths, + string sessionId, + int? loginCommandDelayMs = null) + { + ArgumentNullException.ThrowIfNull(server); + ArgumentNullException.ThrowIfNull(account); + ArgumentNullException.ThrowIfNull(character); + ArgumentNullException.ThrowIfNull(install); + ArgumentNullException.ThrowIfNull(paths); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + + (string configFilePath, string statusFilePath, string stderrLogPath) = + BuildSessionPaths(paths, sessionId); + + SessionCharacterSelector? selector = character.LaunchMode == LaunchMode.GuiSelect + ? null + : BuildSelector(character); + + SessionPolicyDescriptor? policy = character.LaunchMode == LaunchMode.Headless + ? new SessionPolicyDescriptor() + : null; + + var descriptor = new SessionDescriptor + { + Id = sessionId, + Endpoint = new SessionEndpointDescriptor + { + Host = server.Host, + Port = server.Port, + }, + Account = account.Account, + Character = selector, + Policy = policy, + Credential = new SessionCredentialDescriptor(), + // LA5 distinguishes an omitted allow-list (load all, preserving + // the developer flow) from an explicit empty list (load none). + Plugins = [.. character.Plugins], + LoginCommands = character.LoginCommands.Count > 0 + ? [.. character.LoginCommands] + : null, + LoginCommandDelayMs = loginCommandDelayMs, + StatusFile = statusFilePath, + }; + + var document = new SessionConfigDocument + { + Process = new SessionProcessSettings + { + Content = new SessionContentDescriptor + { + DatDirectory = install.DatDirectory, + PreparedAssetPath = install.PreparedAssetPath, + }, + }, + Sessions = [descriptor], + }; + + return new ComposedSessionConfig( + sessionId, + configFilePath, + statusFilePath, + stderrLogPath, + document); + } + + /// + /// Builds a probe session-config document (Campaign LA plan §LA2/ + /// §LA3 review finding F2): the session carries mode: "probe", + /// no character selector, and no policy — the host + /// reports the account's character roster over the status stream and + /// exits without entering the world. Probes carry an explicit empty + /// plugins allow-list so a plugin installed on the machine cannot + /// run merely because the probe has no character-level plugin settings. + /// + public static ComposedSessionConfig ComposeProbe( + ServerProfile server, + AccountProfile account, + LauncherInstallRecord install, + ApplicationPathSet paths, + string sessionId) + { + ArgumentNullException.ThrowIfNull(server); + ArgumentNullException.ThrowIfNull(account); + ArgumentNullException.ThrowIfNull(install); + ArgumentNullException.ThrowIfNull(paths); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + + (string configFilePath, string statusFilePath, string stderrLogPath) = + BuildSessionPaths(paths, sessionId); + + var descriptor = new SessionDescriptor + { + Id = sessionId, + Mode = "probe", + Endpoint = new SessionEndpointDescriptor + { + Host = server.Host, + Port = server.Port, + }, + Account = account.Account, + Character = null, + Policy = null, + Credential = new SessionCredentialDescriptor(), + Plugins = [], + LoginCommands = null, + LoginCommandDelayMs = null, + StatusFile = statusFilePath, + }; + + var document = new SessionConfigDocument + { + Process = new SessionProcessSettings + { + Content = new SessionContentDescriptor + { + DatDirectory = install.DatDirectory, + PreparedAssetPath = install.PreparedAssetPath, + }, + }, + Sessions = [descriptor], + }; + + return new ComposedSessionConfig( + sessionId, + configFilePath, + statusFilePath, + stderrLogPath, + document); + } + + /// Composes and writes session.json to + /// , creating the + /// per-session directory. The status file itself is created by the + /// launched host, not the launcher. + public static ComposedSessionConfig ComposeAndWrite( + ServerProfile server, + AccountProfile account, + CharacterProfile character, + LauncherInstallRecord install, + ApplicationPathSet paths, + string sessionId, + int? loginCommandDelayMs = null) + { + ComposedSessionConfig composed = Compose( + server, + account, + character, + install, + paths, + sessionId, + loginCommandDelayMs); + + return Write(composed); + } + + /// Probe counterpart to . It + /// writes the pinned mode: "probe" document and never includes + /// a character selector, policy, plugin set, login commands, or password. + /// + public static ComposedSessionConfig ComposeProbeAndWrite( + ServerProfile server, + AccountProfile account, + LauncherInstallRecord install, + ApplicationPathSet paths, + string sessionId) => + Write(ComposeProbe(server, account, install, paths, sessionId)); + + private static ComposedSessionConfig Write(ComposedSessionConfig composed) + { + string? directory = Path.GetDirectoryName(composed.ConfigFilePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + using FileStream stream = File.Create(composed.ConfigFilePath); + JsonSerializer.Serialize(stream, composed.Document, SerializerOptions); + + return composed; + } + + /// Serializes the composed document exactly as + /// would write it — used by golden-file + /// tests that assert on the JSON text without touching disk. + public static string Serialize(SessionConfigDocument document) => + JsonSerializer.Serialize(document, SerializerOptions); + + private static (string ConfigFilePath, string StatusFilePath, string StderrLogPath) + BuildSessionPaths( + ApplicationPathSet paths, + string sessionId) + { + string sessionDirectory = Path.Combine( + paths.CacheDirectory, + "launcher", + "sessions", + sessionId); + + return ( + Path.Combine(sessionDirectory, "session.json"), + Path.Combine(sessionDirectory, "status.jsonl"), + // fix #406 sibling gap: lives beside status.jsonl in the same + // per-session directory. + Path.Combine(sessionDirectory, "client.err.log")); + } + + private static SessionCharacterSelector BuildSelector(CharacterProfile character) + { + // A parsed id of 0 is not a usable selector — both host loaders + // (App/Headless) reject `id: 0` outright, so falling through to + // the name selector here is the only shape that reaches a real + // character (Campaign LA plan §LA3 review finding F10). + if (CharacterIdFormat.TryParse(character.Id, out uint id) && id != 0) + { + return new SessionCharacterSelector { Id = id }; + } + + if (!string.IsNullOrWhiteSpace(character.Name)) + { + return new SessionCharacterSelector { Name = character.Name }; + } + + throw new InvalidOperationException( + "Character has neither a usable id nor a name to select by."); + } +} diff --git a/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs b/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs new file mode 100644 index 00000000..1fc31f4d --- /dev/null +++ b/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs @@ -0,0 +1,154 @@ +namespace AcDream.Launcher.Core.Launching; + +/// +/// The per-launch session-config document written to +/// <CacheDirectory>/launcher/sessions/<sessionId>/session.json +/// and consumed by AcDream.Headless --config / (LA1) +/// AcDream.App --session-config. +/// +/// +/// PINNED CONTRACT (Campaign LA plan §LA3): this is the Slice K1 +/// HeadlessConfiguration version-1 shape extended with optional +/// launcher fields (plugins, loginCommands, +/// loginCommandDelayMs, statusFile). Launcher.Core defines +/// its own DTOs rather than referencing AcDream.Headless — the +/// project reference set for this assembly is AcDream.Platform +/// ONLY (no game-solution dependency; see LA3 acceptance). +/// Serialized camelCase via , with +/// null optional members omitted from the written JSON. +/// +/// +public sealed class SessionConfigDocument +{ + public int Version { get; init; } = 1; + + public SessionProcessSettings Process { get; init; } = new(); + + public List Sessions { get; init; } = []; +} + +public sealed class SessionProcessSettings +{ + /// + /// PINNED CONTRACT (Campaign LA plan §LA3 review, finding F1): the + /// paths KEY is entirely OMITTED from the written JSON unless + /// a caller explicitly supplies overrides — never an empty object. + /// The App-side loader parses with strict + /// UnmappedMemberHandling.Disallow and has no paths + /// member of its own, so an emitted "paths":{} is a null- + /// omission artifact (the object's own members are all optional and + /// omit cleanly, but the containing property was never null itself) + /// that would fail every gui/guiSelect launch at config load. + /// + public SessionPathOverrides? Paths { get; init; } + + public SessionContentDescriptor Content { get; init; } = new(); +} + +/// All three members are optional overrides; a host resolves +/// its own default ApplicationPathSet when a member is +/// omitted. +public sealed class SessionPathOverrides +{ + public string? ConfigDirectory { get; init; } + + public string? DataDirectory { get; init; } + + public string? CacheDirectory { get; init; } +} + +// NOTE: these DTOs are write-only (Launcher.Core composes and serializes +// them; it never deserializes a session-config document back). Members +// that the pinned contract calls "always present" therefore use plain +// non-nullable defaults rather than C#'s `required` modifier — a +// `required` member cannot be given a `= new()` default on a containing +// type without a [SetsRequiredMembers] constructor, and correctness here +// is enforced by SessionConfigComposer's tests, not the compiler. + +public sealed class SessionContentDescriptor +{ + public string DatDirectory { get; init; } = string.Empty; + + public string PreparedAssetPath { get; init; } = string.Empty; +} + +public sealed class SessionDescriptor +{ + public string Id { get; init; } = string.Empty; + + /// Present only for a probe session ("probe", + /// Campaign LA plan §LA2/§LA3) — the host reports the account's + /// character roster and exits without entering the world. OMITTED + /// entirely for a normal gui/guiSelect/headless play session. + /// + public string? Mode { get; init; } + + public SessionEndpointDescriptor Endpoint { get; init; } = new(); + + public string Account { get; init; } = string.Empty; + + /// Exactly one of index/id/name when present. OMITTED + /// entirely for a guiSelect launch (retail character-select + /// screen instead of auto-enter). + public SessionCharacterSelector? Character { get; init; } + + /// Present only for a headless launch (the idle + /// bot policy). Omitted for gui/guiSelect. + public SessionPolicyDescriptor? Policy { get; init; } + + public SessionCredentialDescriptor Credential { get; init; } = new(); + + /// Plugin allow-list. Omitted or JSON null means load all + /// discovered plugins (the developer flow); an explicit empty array means + /// load none. Launcher-composed normal-empty and probe sessions therefore + /// emit []. + public List? Plugins { get; init; } + + /// Omitted (never an empty array) when the character has no + /// configured login commands. + public List? LoginCommands { get; init; } + + /// Overrides the host's default 500 ms inter-command + /// delay when set; omitted otherwise. + public int? LoginCommandDelayMs { get; init; } + + public string StatusFile { get; init; } = string.Empty; +} + +public sealed class SessionEndpointDescriptor +{ + public string Host { get; init; } = string.Empty; + + public int Port { get; init; } +} + +/// Exactly one of // +/// is set by . +/// +public sealed class SessionCharacterSelector +{ + public int? Index { get; init; } + + public uint? Id { get; init; } + + public string? Name { get; init; } +} + +/// Campaign LA composes exactly the idle bot policy (LA2) +/// for headless launches — the launcher never asks for any other +/// policy id. +public sealed class SessionPolicyDescriptor +{ + public string Id { get; init; } = "idle"; +} + +/// Always the standardInput provider — the launcher pipes +/// the account password to the child's stdin and never places it in the +/// session-config document, process arguments, or environment (see +/// ). +public sealed class SessionCredentialDescriptor +{ + public string Provider { get; init; } = "standardInput"; + + public string Reference { get; init; } = "session"; +} diff --git a/src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs b/src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs new file mode 100644 index 00000000..335f58cb --- /dev/null +++ b/src/AcDream.Launcher.Core/Launching/WindowsSystemChildProcess.cs @@ -0,0 +1,997 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace AcDream.Launcher.Core.Launching; + +/// +/// Windows launcher child created without a shell as a true console process- +/// group leader. The native start is deliberately narrow: it exists only +/// because does not expose +/// CREATE_NEW_PROCESS_GROUP while the launcher must retain redirected stdin. +/// +internal sealed class WindowsSystemChildProcess : ILauncherChildProcess +{ + private readonly LauncherProcessSpec _spec; + private readonly IWindowsConsoleControl _consoleControl; + private Process? _process; + private TextWriter? _standardInput; + private int _processGroupId; + private bool _raisingEnabled; + // fix #406 sibling gap: null unless _spec.StderrLogPath was set. + private BoundedProcessOutputCapture? _stderrCapture; + private FileStream? _stderrReadStream; + private Thread? _stderrPumpThread; + + internal WindowsSystemChildProcess( + LauncherProcessSpec spec, + IWindowsConsoleControl? consoleControl = null) + { + _spec = spec ?? throw new ArgumentNullException(nameof(spec)); + _consoleControl = consoleControl ?? WindowsConsoleControl.Instance; + } + + public bool HasExited => RequireProcess().HasExited; + + public int ExitCode => RequireProcess().ExitCode; + + public TextWriter StandardInput => _standardInput + ?? throw new InvalidOperationException("The child process has not started."); + + public event EventHandler? Exited; + + public void Start() + { + if (_process is not null) + { + throw new InvalidOperationException("The child process already started."); + } + + WindowsProcessStartResult started = WindowsProcessNative.Start(_spec); + try + { + _process = Process.GetProcessById(started.ProcessId); + _process.EnableRaisingEvents = true; + _process.Exited += OnExited; + _raisingEnabled = true; + _standardInput = started.TakeStandardInput(); + _processGroupId = started.ProcessId; + if (!string.IsNullOrWhiteSpace(_spec.StderrLogPath)) + { + // fix #406 sibling gap: drain the real stderr pipe + // WindowsProcessNative.StartCore created for this spec into + // a bounded file, BEFORE resuming the suspended child below + // — the pump is already running by the time the child can + // write anything. + SafeFileHandle stderrRead = started.TakeStandardErrorRead() + ?? throw new InvalidOperationException( + "The launcher child stderr pipe was not created."); + _stderrCapture = new BoundedProcessOutputCapture(_spec.StderrLogPath); + _stderrReadStream = new FileStream( + stderrRead, + FileAccess.Read, + 4096, + isAsync: false); + _stderrPumpThread = new Thread(PumpStderr) + { + IsBackground = true, + Name = "acdream-launcher-stderr-pump", + }; + _stderrPumpThread.Start(); + } + + started.Resume(); + } + catch + { + started.Terminate(); + _standardInput?.Dispose(); + _standardInput = null; + _stderrReadStream?.Dispose(); + _stderrReadStream = null; + _stderrCapture?.Dispose(); + _stderrCapture = null; + if (_process is not null) + { + if (_raisingEnabled) + { + _process.Exited -= OnExited; + } + + _process.Dispose(); + _process = null; + } + + throw; + } + finally + { + started.Dispose(); + } + } + + /// + /// Runs on a dedicated background thread for the lifetime of the + /// capture (fix #406 sibling gap): continuously drains the child's + /// stderr pipe into so the child's writes + /// never block on a full OS pipe buffer, even after the capture sink + /// itself has stopped accepting bytes (size cap reached, or a local + /// I/O failure latched it off — + /// never throws). Returns cleanly once the pipe's write end closes + /// (the child exited) or closes the read end. + /// + private void PumpStderr() + { + FileStream? stream = _stderrReadStream; + BoundedProcessOutputCapture? capture = _stderrCapture; + if (stream is null || capture is null) + { + return; + } + + byte[] buffer = new byte[4096]; + try + { + int read; + while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) + { + capture.Append(buffer.AsSpan(0, read)); + } + } + catch (Exception error) + when (error is IOException or ObjectDisposedException) + { + // The pipe's write end closed (the child exited) or Dispose() + // released the read end concurrently — either way, this pump + // is simply done; never propagate onto this background thread. + } + } + + public bool TryRequestGracefulStop() + { + try + { + if (!_spec.SupportsConsoleGracefulStop + || _process is not { HasExited: false } process + || _processGroupId <= 0) + { + return false; + } + + return _consoleControl.TrySendBreak(process.Id, _processGroupId); + } + catch + { + // The process may have exited between the state check and the + // control request. Graceful-stop attempts never escape Stop(). + return false; + } + } + + public bool CloseMainWindow() => RequireProcess().CloseMainWindow(); + + public void Kill() => RequireProcess().Kill(entireProcessTree: true); + + public bool WaitForExit(TimeSpan timeout) => RequireProcess().WaitForExit(timeout); + + public void Dispose() + { + _standardInput?.Dispose(); + _standardInput = null; + if (_stderrReadStream is not null) + { + // Closing the read end unblocks PumpStderr's pending Read() + // (ObjectDisposedException, caught there). The bounded Join + // lets that last in-flight chunk land in the capture file + // before it is disposed below, without letting a wedged pump + // thread ever hang this Dispose() call. + _stderrReadStream.Dispose(); + _stderrReadStream = null; + _stderrPumpThread?.Join(TimeSpan.FromSeconds(2)); + _stderrPumpThread = null; + } + + _stderrCapture?.Dispose(); + _stderrCapture = null; + if (_process is not null) + { + if (_raisingEnabled) + { + _process.Exited -= OnExited; + } + + _process.Dispose(); + _process = null; + } + } + + private Process RequireProcess() => _process + ?? throw new InvalidOperationException("The child process has not started."); + + private void OnExited(object? sender, EventArgs e) => + Exited?.Invoke(this, EventArgs.Empty); +} + +internal interface IWindowsConsoleControl +{ + bool TrySendBreak(int childProcessId, int childProcessGroupId); +} + +internal sealed class WindowsConsoleControl : IWindowsConsoleControl +{ + private const uint CtrlBreakEvent = 1; + + internal static WindowsConsoleControl Instance { get; } = new(); + + private WindowsConsoleControl() + { + } + + public bool TrySendBreak(int childProcessId, int childProcessGroupId) + { + if (!OperatingSystem.IsWindows() + || childProcessId <= 0 + || childProcessGroupId <= 0) + { + return false; + } + + lock (WindowsConsoleSynchronization.Gate) + { + bool attachedHere = false; + try + { + uint[] processes = new uint[1]; + if (Native.GetConsoleProcessList(processes, 1) == 0) + { + if (!Native.AttachConsole((uint)childProcessId)) + { + return false; + } + + attachedHere = true; + } + + return Native.GenerateConsoleCtrlEvent( + CtrlBreakEvent, + (uint)childProcessGroupId); + } + catch + { + return false; + } + finally + { + if (attachedHere) + { + _ = Native.FreeConsole(); + } + } + } + } + + private static class Native + { + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool AttachConsole(uint processId); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool FreeConsole(); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool GenerateConsoleCtrlEvent( + uint controlEvent, + uint processGroupId); + + [DllImport("kernel32.dll", SetLastError = true)] + internal static extern uint GetConsoleProcessList( + [Out] uint[] processList, + uint processCount); + } +} + +/// +/// A process can be attached to only one console. Child creation and targeted +/// control-event attachment therefore share one process-wide gate. +/// +internal static class WindowsConsoleSynchronization +{ + internal static object Gate { get; } = new(); +} + +internal sealed class WindowsProcessStartResult : IDisposable +{ + private readonly SafeKernelHandle _processHandle; + private readonly SafeKernelHandle _threadHandle; + private SafeFileHandle? _standardInput; + private SafeFileHandle? _stderrRead; + private bool _resumed; + + internal WindowsProcessStartResult( + int processId, + SafeKernelHandle processHandle, + SafeKernelHandle threadHandle, + SafeFileHandle standardInput, + SafeFileHandle? stderrRead = null) + { + ProcessId = processId; + _processHandle = processHandle; + _threadHandle = threadHandle; + _standardInput = standardInput; + _stderrRead = stderrRead; + } + + internal int ProcessId { get; } + + internal TextWriter TakeStandardInput() + { + SafeFileHandle handle = _standardInput + ?? throw new InvalidOperationException("Standard input was already claimed."); + var stream = new FileStream(handle, FileAccess.Write, 4096, isAsync: false); + _standardInput = null; + try + { + return new StreamWriter( + stream, + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)) + { + AutoFlush = true, + }; + } + catch + { + stream.Dispose(); + throw; + } + } + + /// + /// Transfers ownership of the parent-side stderr pipe read handle (fix + /// #406 sibling gap) — non-null only when + /// was set, in which case + /// created a real pipe for the child's stderr instead of the usual + /// duplicate-or-NUL handle. Returns null if capture was not requested, + /// or if this handle was already claimed. The caller owns disposal + /// after this call. + /// + internal SafeFileHandle? TakeStandardErrorRead() + { + SafeFileHandle? handle = _stderrRead; + _stderrRead = null; + return handle; + } + + internal void Resume() + { + if (WindowsProcessNative.ResumeThread(_threadHandle) == uint.MaxValue) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + "The Windows launcher child could not be resumed."); + } + + _resumed = true; + } + + internal void Terminate() + { + if (!_processHandle.IsInvalid) + { + _ = WindowsProcessNative.TerminateProcess(_processHandle, 74); + } + } + + public void Dispose() + { + if (!_resumed) + { + Terminate(); + } + + _standardInput?.Dispose(); + _stderrRead?.Dispose(); + _threadHandle.Dispose(); + _processHandle.Dispose(); + } +} + +internal static class WindowsProcessNative +{ + private const uint CreateSuspended = 0x00000004; + private const uint CreateNewProcessGroup = 0x00000200; + private const uint ExtendedStartupInfoPresent = 0x00080000; + private const uint StartfUseStdHandles = 0x00000100; + private const short SwHide = 0; + private const uint HandleFlagInherit = 0x00000001; + private const uint DuplicateSameAccess = 0x00000002; + private const uint GenericWrite = 0x40000000; + private const uint FileShareRead = 0x00000001; + private const uint FileShareWrite = 0x00000002; + private const uint OpenExisting = 3; + private const uint FileAttributeNormal = 0x00000080; + private const int StdOutputHandle = -11; + private const int StdErrorHandle = -12; + private static readonly IntPtr ProcThreadAttributeHandleList = new(0x00020002); + + internal static WindowsProcessStartResult Start(LauncherProcessSpec spec) + { + ArgumentException.ThrowIfNullOrWhiteSpace(spec.ExecutablePath); + ArgumentNullException.ThrowIfNull(spec.Arguments); + + lock (WindowsConsoleSynchronization.Gate) + { + bool allocatedConsole = false; + try + { + // An Avalonia launcher started from Explorer has no console. + // CREATE_NEW_PROCESS_GROUP alone does not allocate one, and a + // console-less group cannot receive GenerateConsoleCtrlEvent. + // Allocate one only for the creation transaction, hide it, + // let the group leader inherit it, then detach the launcher. + // Each such child consequently owns a distinct console as + // well as a distinct process group. + if (!HasConsole()) + { + if (!AllocConsole()) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + "The Windows launcher could not allocate the child console."); + } + + allocatedConsole = true; + IntPtr consoleWindow = GetConsoleWindow(); + if (consoleWindow != IntPtr.Zero) + { + _ = ShowWindow(consoleWindow, SwHide); + } + } + + return StartCore(spec); + } + finally + { + if (allocatedConsole) + { + _ = FreeConsole(); + } + } + } + } + + private static WindowsProcessStartResult StartCore(LauncherProcessSpec spec) + { + SafeFileHandle? parentInput = null; + // fix #406 sibling gap: when the spec requests stderr capture, the + // child's stderr handle is a real pipe (this parent-side read end) + // instead of the usual duplicate-or-NUL handle below. + SafeFileHandle? parentStderrRead = null; + SafeHandle? childError = null; + try + { + using SafeFileHandle childInput = CreateChildInputPipe( + out SafeFileHandle createdParentInput); + parentInput = createdParentInput; + using SafeKernelHandle childOutput = DuplicateOrOpenNull(StdOutputHandle); + childError = string.IsNullOrWhiteSpace(spec.StderrLogPath) + ? DuplicateOrOpenNull(StdErrorHandle) + : CreateChildOutputPipe(out parentStderrRead); + using var attributes = new ProcessThreadAttributeList( + childInput.DangerousGetHandle(), + childOutput.DangerousGetHandle(), + childError.DangerousGetHandle()); + + var startup = new StartupInfoEx + { + StartupInfo = new StartupInfo + { + Size = Marshal.SizeOf(), + Flags = StartfUseStdHandles, + StandardInput = childInput.DangerousGetHandle(), + StandardOutput = childOutput.DangerousGetHandle(), + StandardError = childError.DangerousGetHandle(), + }, + AttributeList = attributes.Pointer, + }; + string executable = ResolveExecutable(spec.ExecutablePath); + string commandLineText = BuildCommandLine(executable, spec.Arguments); + var commandLine = new StringBuilder(commandLineText, commandLineText.Length + 1); + string? workingDirectory = string.IsNullOrWhiteSpace(spec.WorkingDirectory) + ? null + : Path.GetFullPath(spec.WorkingDirectory); + + if (!CreateProcessW( + executable, + commandLine, + IntPtr.Zero, + IntPtr.Zero, + inheritHandles: true, + CreateSuspended | CreateNewProcessGroup | ExtendedStartupInfoPresent, + IntPtr.Zero, + workingDirectory, + ref startup, + out ProcessInformation information)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + "The Windows launcher child could not be created."); + } + + var processHandle = new SafeKernelHandle( + information.Process, + ownsHandle: true); + var threadHandle = new SafeKernelHandle( + information.Thread, + ownsHandle: true); + try + { + var result = new WindowsProcessStartResult( + checked((int)information.ProcessId), + processHandle, + threadHandle, + parentInput, + parentStderrRead); + parentInput = null; + parentStderrRead = null; + return result; + } + catch + { + _ = TerminateProcess(processHandle, 74); + threadHandle.Dispose(); + processHandle.Dispose(); + throw; + } + } + finally + { + parentInput?.Dispose(); + parentStderrRead?.Dispose(); + childError?.Dispose(); + } + } + + private static bool HasConsole() + { + uint[] processes = new uint[1]; + return GetConsoleProcessList(processes, 1) != 0; + } + + internal static uint ResumeThread(SafeKernelHandle thread) => + NativeResumeThread(thread); + + internal static bool TerminateProcess(SafeKernelHandle process, uint exitCode) => + NativeTerminateProcess(process, exitCode); + + internal static string BuildCommandLine( + string executable, + IReadOnlyList arguments) + { + var builder = new StringBuilder(); + AppendQuotedArgument(builder, executable); + foreach (string argument in arguments) + { + ArgumentNullException.ThrowIfNull(argument); + builder.Append(' '); + AppendQuotedArgument(builder, argument); + } + + return builder.ToString(); + } + + private static void AppendQuotedArgument(StringBuilder builder, string value) + { + builder.Append('"'); + int backslashes = 0; + foreach (char character in value) + { + if (character == '\\') + { + backslashes++; + continue; + } + + if (character == '"') + { + builder.Append('\\', backslashes * 2 + 1); + builder.Append('"'); + backslashes = 0; + continue; + } + + builder.Append('\\', backslashes); + backslashes = 0; + builder.Append(character); + } + + builder.Append('\\', backslashes * 2); + builder.Append('"'); + } + + private static SafeFileHandle CreateChildInputPipe(out SafeFileHandle parentInput) + { + var security = new SecurityAttributes + { + Length = Marshal.SizeOf(), + InheritHandle = true, + }; + if (!CreatePipe(out IntPtr read, out IntPtr write, ref security, 0)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + "The launcher child stdin pipe could not be created."); + } + + var child = new SafeFileHandle(read, ownsHandle: true); + parentInput = new SafeFileHandle(write, ownsHandle: true); + if (!SetHandleInformation( + parentInput, + HandleFlagInherit, + 0)) + { + int error = Marshal.GetLastWin32Error(); + child.Dispose(); + parentInput.Dispose(); + throw new Win32Exception(error, + "The launcher child stdin pipe could not be isolated."); + } + + return child; + } + + /// + /// Mirror of with the roles + /// reversed (fix #406 sibling gap): the CHILD gets the pipe's WRITE + /// end (its stderr handle, inheritable across + /// ), the PARENT keeps the READ end + /// (inherit flag cleared, exactly like 's + /// counterpart on the stdin pipe) so the launcher can drain the + /// child's stderr into a bounded file. + /// + private static SafeFileHandle CreateChildOutputPipe(out SafeFileHandle parentRead) + { + var security = new SecurityAttributes + { + Length = Marshal.SizeOf(), + InheritHandle = true, + }; + if (!CreatePipe(out IntPtr read, out IntPtr write, ref security, 0)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + "The launcher child stderr pipe could not be created."); + } + + var child = new SafeFileHandle(write, ownsHandle: true); + parentRead = new SafeFileHandle(read, ownsHandle: true); + if (!SetHandleInformation( + parentRead, + HandleFlagInherit, + 0)) + { + int error = Marshal.GetLastWin32Error(); + child.Dispose(); + parentRead.Dispose(); + throw new Win32Exception(error, + "The launcher child stderr pipe could not be isolated."); + } + + return child; + } + + private static SafeKernelHandle DuplicateOrOpenNull(int standardHandle) + { + IntPtr source = GetStdHandle(standardHandle); + if (source != IntPtr.Zero && source != new IntPtr(-1)) + { + IntPtr current = GetCurrentProcess(); + if (DuplicateHandle( + current, + source, + current, + out IntPtr duplicate, + 0, + inheritHandle: true, + DuplicateSameAccess)) + { + return new SafeKernelHandle(duplicate, ownsHandle: true); + } + } + + IntPtr nul = CreateFileW( + "NUL", + GenericWrite, + FileShareRead | FileShareWrite, + IntPtr.Zero, + OpenExisting, + FileAttributeNormal, + IntPtr.Zero); + if (nul == IntPtr.Zero || nul == new IntPtr(-1)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + "The launcher child fallback output handle could not be opened."); + } + + var handle = new SafeKernelHandle(nul, ownsHandle: true); + if (!SetHandleInformation(handle, HandleFlagInherit, HandleFlagInherit)) + { + int error = Marshal.GetLastWin32Error(); + handle.Dispose(); + throw new Win32Exception(error, + "The launcher child fallback output handle could not be inherited."); + } + + return handle; + } + + private static string ResolveExecutable(string executable) + { + if (Path.IsPathFullyQualified(executable)) + { + return Path.GetFullPath(executable); + } + + var buffer = new StringBuilder(32_768); + uint length = SearchPathW( + null, + executable, + null, + (uint)buffer.Capacity, + buffer, + IntPtr.Zero); + if (length == 0 || length >= buffer.Capacity) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + $"Launcher child executable '{executable}' was not found."); + } + + return Path.GetFullPath(buffer.ToString()); + } + + private sealed class ProcessThreadAttributeList : IDisposable + { + private IntPtr _pointer; + private IntPtr _handles; + private bool _initialized; + + internal ProcessThreadAttributeList(params IntPtr[] handles) + { + nuint size = 0; + _ = InitializeProcThreadAttributeList( + IntPtr.Zero, + 1, + 0, + ref size); + _pointer = Marshal.AllocHGlobal(checked((nint)size)); + if (!InitializeProcThreadAttributeList(_pointer, 1, 0, ref size)) + { + int error = Marshal.GetLastWin32Error(); + Dispose(); + throw new Win32Exception(error, + "The launcher child handle list could not be initialized."); + } + _initialized = true; + + _handles = Marshal.AllocHGlobal(handles.Length * IntPtr.Size); + for (int index = 0; index < handles.Length; index++) + { + Marshal.WriteIntPtr(_handles, index * IntPtr.Size, handles[index]); + } + + if (!UpdateProcThreadAttribute( + _pointer, + 0, + ProcThreadAttributeHandleList, + _handles, + checked((nuint)(handles.Length * IntPtr.Size)), + IntPtr.Zero, + IntPtr.Zero)) + { + int error = Marshal.GetLastWin32Error(); + Dispose(); + throw new Win32Exception(error, + "The launcher child inherited-handle list could not be set."); + } + } + + internal IntPtr Pointer => _pointer; + + public void Dispose() + { + if (_pointer != IntPtr.Zero) + { + if (_initialized) + { + DeleteProcThreadAttributeList(_pointer); + _initialized = false; + } + Marshal.FreeHGlobal(_pointer); + _pointer = IntPtr.Zero; + } + + if (_handles != IntPtr.Zero) + { + Marshal.FreeHGlobal(_handles); + _handles = IntPtr.Zero; + } + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct SecurityAttributes + { + internal int Length; + internal IntPtr SecurityDescriptor; + [MarshalAs(UnmanagedType.Bool)] internal bool InheritHandle; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct StartupInfo + { + internal int Size; + internal string? Reserved; + internal string? Desktop; + internal string? Title; + internal int X; + internal int Y; + internal int XSize; + internal int YSize; + internal int XCountChars; + internal int YCountChars; + internal int FillAttribute; + internal uint Flags; + internal short ShowWindow; + internal short Reserved2Size; + internal IntPtr Reserved2; + internal IntPtr StandardInput; + internal IntPtr StandardOutput; + internal IntPtr StandardError; + } + + [StructLayout(LayoutKind.Sequential)] + private struct StartupInfoEx + { + internal StartupInfo StartupInfo; + internal IntPtr AttributeList; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ProcessInformation + { + internal IntPtr Process; + internal IntPtr Thread; + internal uint ProcessId; + internal uint ThreadId; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreateProcessW( + string applicationName, + StringBuilder commandLine, + IntPtr processAttributes, + IntPtr threadAttributes, + [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, + uint creationFlags, + IntPtr environment, + string? currentDirectory, + ref StartupInfoEx startupInfo, + out ProcessInformation processInformation); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CreatePipe( + out IntPtr readPipe, + out IntPtr writePipe, + ref SecurityAttributes pipeAttributes, + uint size); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetHandleInformation( + SafeHandle handle, + uint mask, + uint flags); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AllocConsole(); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool FreeConsole(); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint GetConsoleProcessList( + [Out] uint[] processList, + uint processCount); + + [DllImport("kernel32.dll")] + private static extern IntPtr GetConsoleWindow(); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ShowWindow(IntPtr window, int commandShow); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr GetStdHandle(int standardHandle); + + [DllImport("kernel32.dll")] + private static extern IntPtr GetCurrentProcess(); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DuplicateHandle( + IntPtr sourceProcess, + IntPtr sourceHandle, + IntPtr targetProcess, + out IntPtr targetHandle, + uint desiredAccess, + [MarshalAs(UnmanagedType.Bool)] bool inheritHandle, + uint options); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr CreateFileW( + string fileName, + uint desiredAccess, + uint shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint SearchPathW( + string? path, + string fileName, + string? extension, + uint bufferLength, + StringBuilder buffer, + IntPtr filePart); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool InitializeProcThreadAttributeList( + IntPtr attributeList, + int attributeCount, + int flags, + ref nuint size); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool UpdateProcThreadAttribute( + IntPtr attributeList, + uint flags, + IntPtr attribute, + IntPtr value, + nuint size, + IntPtr previousValue, + IntPtr returnSize); + + [DllImport("kernel32.dll")] + private static extern void DeleteProcThreadAttributeList(IntPtr attributeList); + + [DllImport("kernel32.dll", EntryPoint = "ResumeThread", SetLastError = true)] + private static extern uint NativeResumeThread(SafeKernelHandle thread); + + [DllImport("kernel32.dll", EntryPoint = "TerminateProcess", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool NativeTerminateProcess( + SafeKernelHandle process, + uint exitCode); +} + +internal sealed class SafeKernelHandle : SafeHandleZeroOrMinusOneIsInvalid +{ + internal SafeKernelHandle(IntPtr handle, bool ownsHandle) + : base(ownsHandle) + { + SetHandle(handle); + } + + protected override bool ReleaseHandle() => CloseHandle(handle); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr handle); +} diff --git a/src/AcDream.Launcher.Core/Orchestration/ILauncherOrchestrator.cs b/src/AcDream.Launcher.Core/Orchestration/ILauncherOrchestrator.cs new file mode 100644 index 00000000..e52c443f --- /dev/null +++ b/src/AcDream.Launcher.Core/Orchestration/ILauncherOrchestrator.cs @@ -0,0 +1,92 @@ +using AcDream.Launcher.Core.Launching; +using AcDream.Launcher.Core.Profiles; + +namespace AcDream.Launcher.Core.Orchestration; + +/// +/// Canonical state/mutation surface projected by the Avalonia launcher. The UI +/// never owns a second profile document, process map, status tail, or launch +/// transaction; it asks for immutable snapshots and sends typed mutations here. +/// +public interface ILauncherOrchestrator : IDisposable +{ + event EventHandler? StateChanged; + + void LoadProfiles(); + + LauncherStateSnapshot GetSnapshot(); + + LauncherCapability GetLaunchCapability(LaunchMode mode); + + LauncherCapability GetAccountLaunchCapability( + string serverName, + string accountName, + LaunchMode mode); + + LauncherCapability GetProbeCapability(string serverName, string accountName); + + void SetInstallRecord(LauncherInstallRecord? installRecord); + + void AddServer(string name, string host, int port); + + void EditServer(string name, string newName, string newHost, int newPort); + + void RemoveServer(string name); + + void AddAccount(string serverName, string accountName, string password); + + void EditAccount( + string serverName, + string accountName, + string newAccountName, + string? newPassword); + + void RemoveAccount(string serverName, string accountName); + + void AddCharacter( + string serverName, + string accountName, + string characterName, + string? characterId); + + void EditCharacterIdentity( + string serverName, + string accountName, + string characterName, + string newCharacterName, + string? newCharacterId); + + void UpdateCharacterSettings( + string serverName, + string accountName, + string characterName, + LaunchMode launchMode, + IReadOnlyList plugins, + IReadOnlyList loginCommands); + + void RemoveCharacter( + string serverName, + string accountName, + string characterName); + + Task LaunchAsync( + string serverName, + string accountName, + string? characterName, + LaunchMode mode, + CancellationToken cancellationToken = default); + + Task ProbeAsync( + string serverName, + string accountName, + CancellationToken cancellationToken = default); + + Task StopSessionAsync( + string sessionId, + TimeSpan timeout, + CancellationToken cancellationToken = default); + + void PollStatus(); + + void ClearFinishedSessions(); +} diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs new file mode 100644 index 00000000..4add2816 --- /dev/null +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherExecutableSet.cs @@ -0,0 +1,215 @@ +using AcDream.Launcher.Core.Launching; +using AcDream.Launcher.Core.Profiles; +using AcDream.Launcher.Core.Updates; + +namespace AcDream.Launcher.Core.Orchestration; + +/// +/// Resolves and validates the graphical/headless hosts. Production uses the +/// verified DataDirectory/app/current.json resolver; the explicit-path +/// constructor remains the injectable test seam. +/// +public sealed class LauncherExecutableSet +{ + private readonly Func _fileExists; + private readonly Func _hasUnixExecutePermission; + private readonly Func _resolve; + + public LauncherExecutableSet( + string graphicalHostPath, + string headlessHostPath, + string? workingDirectory = null, + Func? fileExists = null, + Func? hasUnixExecutePermission = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(graphicalHostPath); + ArgumentException.ThrowIfNullOrWhiteSpace(headlessHostPath); + string graphical = graphicalHostPath; + string headless = headlessHostPath; + _resolve = () => new ExecutablePaths(graphical, headless, workingDirectory); + _fileExists = fileExists ?? File.Exists; + _hasUnixExecutePermission = + hasUnixExecutePermission ?? HasUnixExecutePermission; + } + + private LauncherExecutableSet( + Func resolve, + Func? fileExists = null, + Func? hasUnixExecutePermission = null) + { + _resolve = resolve ?? throw new ArgumentNullException(nameof(resolve)); + _fileExists = fileExists ?? File.Exists; + _hasUnixExecutePermission = + hasUnixExecutePermission ?? HasUnixExecutePermission; + } + + public string GraphicalHostPath => _resolve().GraphicalHostPath; + + public string HeadlessHostPath => _resolve().HeadlessHostPath; + + public string? WorkingDirectory => _resolve().WorkingDirectory; + + public LauncherCapability GetAvailability(LaunchMode mode) + { + ExecutablePaths paths; + try + { + paths = _resolve(); + } + catch (Exception ex) when (ex is LauncherUpdateException + or InvalidOperationException + or IOException + or UnauthorizedAccessException) + { + return LauncherCapability.Unavailable( + $"The active versioned client is unavailable: {ex.Message}"); + } + + string path = mode == LaunchMode.Headless + ? paths.HeadlessHostPath + : paths.GraphicalHostPath; + string host = mode == LaunchMode.Headless + ? "headless host" + : "graphical client"; + if (!_fileExists(path)) + { + return LauncherCapability.Unavailable( + $"The co-deployed {host} is missing at '{path}'. Reinstall or update " + + "the client before launching."); + } + + if (OperatingSystem.IsLinux() && !_hasUnixExecutePermission(path)) + { + return LauncherCapability.Unavailable( + $"The co-deployed {host} at '{path}' exists but is not executable. " + + "Restore its executable permission (for example, chmod +x) or " + + "reinstall/update the client before launching."); + } + + return LauncherCapability.Available; + } + + public LauncherProcessSpec CreatePlaySpec( + LaunchMode mode, + string configFilePath, + string? stderrLogPath = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath); + ExecutablePaths paths = RequireAvailable(mode); + + return mode == LaunchMode.Headless + ? new LauncherProcessSpec( + paths.HeadlessHostPath, + ["--config", configFilePath], + paths.WorkingDirectory, + StderrLogPath: stderrLogPath) + : new LauncherProcessSpec( + paths.GraphicalHostPath, + ["--session-config", configFilePath], + paths.WorkingDirectory, + SupportsConsoleGracefulStop: false, + StderrLogPath: stderrLogPath); + } + + public LauncherProcessSpec CreateProbeSpec( + string configFilePath, + string? stderrLogPath = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath); + ExecutablePaths paths = RequireAvailable(LaunchMode.Headless); + return new LauncherProcessSpec( + paths.HeadlessHostPath, + ["--config", configFilePath], + paths.WorkingDirectory, + StderrLogPath: stderrLogPath); + } + + public static LauncherExecutableSet FromDirectory(string directory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(directory); + string fullDirectory = Path.GetFullPath(directory); + string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty; + return new LauncherExecutableSet( + Path.Combine(fullDirectory, "AcDream.App" + executableSuffix), + Path.Combine(fullDirectory, "acdream-headless" + executableSuffix), + fullDirectory); + } + + /// + /// Dynamic production resolver. The store cache is admitted only after a + /// strict startup/update verification, and a pointer swap changes the + /// binaries selected for the next session without replacing LA9 content. + /// + public static LauncherExecutableSet FromCurrentVersionStore( + ClientVersionStore store) + { + ArgumentNullException.ThrowIfNull(store); + return new LauncherExecutableSet(() => + { + ClientVersionResolution resolution = store.CachedResolution; + if (!resolution.IsVerified || resolution.Directory is null) + { + throw new LauncherUpdateException(resolution.Status); + } + + return FromDirectoryPaths(resolution.Directory); + }); + } + + public static LauncherExecutableSet Unavailable(string reason) + { + ArgumentException.ThrowIfNullOrWhiteSpace(reason); + return new LauncherExecutableSet( + () => throw new LauncherUpdateException(reason)); + } + + private ExecutablePaths RequireAvailable(LaunchMode mode) + { + LauncherCapability capability = GetAvailability(mode); + if (!capability.IsAvailable) + { + throw new LauncherOperationException( + capability.Reason ?? "The selected launcher host is unavailable."); + } + + return _resolve(); + } + + private static ExecutablePaths FromDirectoryPaths(string directory) + { + string fullDirectory = Path.GetFullPath(directory); + string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty; + return new ExecutablePaths( + Path.Combine(fullDirectory, "AcDream.App" + executableSuffix), + Path.Combine(fullDirectory, "acdream-headless" + executableSuffix), + fullDirectory); + } + + private static bool HasUnixExecutePermission(string path) + { + if (!OperatingSystem.IsLinux()) + { + return true; + } + + try + { + const UnixFileMode executeBits = + UnixFileMode.UserExecute + | UnixFileMode.GroupExecute + | UnixFileMode.OtherExecute; + return (File.GetUnixFileMode(path) & executeBits) != 0; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Fail closed if the file vanished or its metadata cannot be read + // after the existence check. The next capability refresh retries. + return false; + } + } + + private sealed record ExecutablePaths( + string GraphicalHostPath, + string HeadlessHostPath, + string? WorkingDirectory); +} diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs new file mode 100644 index 00000000..9bb48560 --- /dev/null +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs @@ -0,0 +1,1350 @@ +using AcDream.Launcher.Core.Launching; +using AcDream.Launcher.Core.Profiles; +using AcDream.Launcher.Core.Status; +using AcDream.Launcher.Core.Updates; +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Orchestration; + +/// +/// The one launcher-side state/orchestration owner. It owns the profile store, +/// config composition transaction, supervised child set, status tails, roster +/// folding, and platform capability gates. Avalonia receives immutable, +/// credential-free snapshots and sends typed commands back through +/// . +/// +public sealed class LauncherOrchestrator : ILauncherOrchestrator +{ + private const string FirstRunRequired = + "Client content is not configured. Complete the first-run setup before launching."; + + private readonly object _gate = new(); + private readonly LauncherProfileStore _profileStore; + private readonly ApplicationPathSet _paths; + private readonly LauncherExecutableSet _executables; + private readonly LauncherPlatformCapabilities _platform; + private readonly ILauncherSessionConfigService _configService; + private readonly ILauncherProcessSupervisorFactory _supervisorFactory; + private readonly IStatusEventSourceFactory _statusSourceFactory; + private readonly Func _sessionIdFactory; + private readonly UpdateSessionBarrier _updateSessionBarrier; + private readonly List _activities = []; + + private LauncherInstallRecord? _installRecord; + private string _installationStatus; + private bool _disposed; + + public LauncherOrchestrator( + LauncherProfileStore profileStore, + ApplicationPathSet paths, + LauncherExecutableSet executables, + LauncherInstallRecord? installRecord = null, + LauncherPlatformCapabilities? platform = null, + ILauncherSessionConfigService? configService = null, + ILauncherProcessSupervisorFactory? supervisorFactory = null, + IStatusEventSourceFactory? statusSourceFactory = null, + Func? sessionIdFactory = null, + string? installationStatus = null, + UpdateSessionBarrier? updateSessionBarrier = null) + { + _profileStore = profileStore ?? throw new ArgumentNullException(nameof(profileStore)); + _paths = paths ?? throw new ArgumentNullException(nameof(paths)); + _executables = executables ?? throw new ArgumentNullException(nameof(executables)); + _installRecord = installRecord; + _platform = platform ?? LauncherPlatformCapabilities.Detect(); + _configService = configService ?? new LauncherSessionConfigService(); + _supervisorFactory = supervisorFactory ?? new LauncherProcessSupervisorFactory(); + _statusSourceFactory = statusSourceFactory ?? new StatusFileTailerFactory(); + _sessionIdFactory = sessionIdFactory ?? CreateSessionId; + _updateSessionBarrier = updateSessionBarrier + ?? new UpdateSessionBarrier(paths.DataDirectory); + _installationStatus = installationStatus + ?? (installRecord is null + ? FirstRunRequired + : "Client content paths are configured."); + } + + public event EventHandler? StateChanged; + + public void LoadProfiles() + { + lock (_gate) + { + ThrowIfDisposed(); + _profileStore.Load(); + } + + RaiseStateChanged(); + } + + public LauncherStateSnapshot GetSnapshot() + { + lock (_gate) + { + ThrowIfDisposed(); + + LauncherServerSnapshot[] servers = _profileStore.Document.Servers + .Select(CreateServerSnapshotLocked) + .ToArray(); + LauncherSessionSnapshot[] sessions = _activities + .OrderByDescending(activity => activity.CreatedAt) + .Select(activity => activity.ToSnapshot()) + .ToArray(); + + return new LauncherStateSnapshot( + servers, + sessions, + _platform, + _installRecord is not null, + _installationStatus); + } + } + + public LauncherCapability GetLaunchCapability(LaunchMode mode) + { + LauncherCapability platformCapability = _platform.ForLaunchMode(mode); + if (!platformCapability.IsAvailable) + { + return platformCapability; + } + + LauncherCapability executableCapability = _executables.GetAvailability(mode); + if (!executableCapability.IsAvailable) + { + return executableCapability; + } + + lock (_gate) + { + ThrowIfDisposed(); + return _installRecord is null + ? LauncherCapability.Unavailable(FirstRunRequired) + : LauncherCapability.Available; + } + } + + public LauncherCapability GetAccountLaunchCapability( + string serverName, + string accountName, + LaunchMode mode) + { + ArgumentException.ThrowIfNullOrWhiteSpace(serverName); + ArgumentException.ThrowIfNullOrWhiteSpace(accountName); + + LauncherCapability capability = GetLaunchCapability(mode); + if (!capability.IsAvailable) + { + return capability; + } + + lock (_gate) + { + ThrowIfDisposed(); + _ = FindAccountLocked(serverName, accountName); + ManagedActivity? active = FindActiveActivityLocked(serverName, accountName); + return active is null + ? LauncherCapability.Available + : LauncherCapability.Unavailable( + $"Stop the running {active.Kind.ToString().ToLowerInvariant()} " + + "for this account before starting another activity."); + } + } + + public LauncherCapability GetProbeCapability(string serverName, string accountName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(serverName); + ArgumentException.ThrowIfNullOrWhiteSpace(accountName); + + LauncherCapability platformCapability = + _platform.ForLaunchMode(LaunchMode.Headless); + if (!platformCapability.IsAvailable) + { + return platformCapability; + } + + LauncherCapability executableCapability = + _executables.GetAvailability(LaunchMode.Headless); + if (!executableCapability.IsAvailable) + { + return executableCapability; + } + + lock (_gate) + { + ThrowIfDisposed(); + _ = FindAccountLocked(serverName, accountName); + if (_installRecord is null) + { + return LauncherCapability.Unavailable(FirstRunRequired); + } + + ManagedActivity? active = FindActiveActivityLocked(serverName, accountName); + return active is null + ? LauncherCapability.Available + : LauncherCapability.Unavailable( + $"Stop the running {active.Kind.ToString().ToLowerInvariant()} " + + "for this account before refreshing its characters."); + } + } + + public void SetInstallRecord(LauncherInstallRecord? installRecord) + { + lock (_gate) + { + ThrowIfDisposed(); + _installRecord = installRecord; + _installationStatus = installRecord is null + ? FirstRunRequired + : "Client content SHA-256, size, and bake-tool version verified."; + } + + RaiseStateChanged(); + } + + public void AddServer(string name, string host, int port) => + MutateProfiles(() => _profileStore.AddServer(name, host, port)); + + public void EditServer(string name, string newName, string newHost, int newPort) => + MutateProfiles(() => + { + EnsureServerIdleLocked(name); + _profileStore.EditServer( + name, + newName: newName, + newHost: newHost, + newPort: newPort); + }); + + public void RemoveServer(string name) => + MutateProfiles(() => + { + EnsureServerIdleLocked(name); + _profileStore.RemoveServer(name); + }); + + public void AddAccount(string serverName, string accountName, string password) => + MutateProfiles(() => + _profileStore.AddAccount(serverName, accountName, password)); + + public void EditAccount( + string serverName, + string accountName, + string newAccountName, + string? newPassword) => + MutateProfiles(() => + { + EnsureAccountIdleLocked(serverName, accountName); + _profileStore.EditAccount( + serverName, + accountName, + newAccount: newAccountName, + newPassword: newPassword); + }); + + public void RemoveAccount(string serverName, string accountName) => + MutateProfiles(() => + { + EnsureAccountIdleLocked(serverName, accountName); + _profileStore.RemoveAccount(serverName, accountName); + }); + + public void AddCharacter( + string serverName, + string accountName, + string characterName, + string? characterId) => + MutateProfiles(() => + _profileStore.AddCharacter( + serverName, + accountName, + characterName, + characterId)); + + public void EditCharacterIdentity( + string serverName, + string accountName, + string characterName, + string newCharacterName, + string? newCharacterId) => + MutateProfiles(() => + { + EnsureCharacterIdleLocked(serverName, accountName, characterName); + _profileStore.EditCharacter( + serverName, + accountName, + characterName, + newName: newCharacterName, + newId: newCharacterId); + }); + + public void UpdateCharacterSettings( + string serverName, + string accountName, + string characterName, + LaunchMode launchMode, + IReadOnlyList plugins, + IReadOnlyList loginCommands) => + MutateProfiles(() => + _profileStore.EditCharacter( + serverName, + accountName, + characterName, + launchMode: launchMode, + plugins: plugins, + loginCommands: loginCommands)); + + public void RemoveCharacter( + string serverName, + string accountName, + string characterName) => + MutateProfiles(() => + { + EnsureCharacterIdleLocked(serverName, accountName, characterName); + _profileStore.RemoveCharacter(serverName, accountName, characterName); + }); + + public Task LaunchAsync( + string serverName, + string accountName, + string? characterName, + LaunchMode mode, + CancellationToken cancellationToken = default) + { + LauncherCapability capability = GetLaunchCapability(mode); + if (!capability.IsAvailable) + { + throw new LauncherOperationException(capability.Reason ?? "Launch is unavailable."); + } + + StartRequest request; + lock (_gate) + { + ThrowIfDisposed(); + if (FindActiveActivityLocked(serverName, accountName) is not null) + { + throw new LauncherOperationException( + "A session or character refresh is already running for this account."); + } + + ServerProfile server = FindServerLocked(serverName); + AccountProfile account = FindAccountLocked(serverName, accountName); + CharacterProfile character; + if (string.IsNullOrWhiteSpace(characterName)) + { + if (mode != LaunchMode.GuiSelect) + { + throw new LauncherOperationException( + "Select a cached character for GUI or headless launch."); + } + + character = new CharacterProfile + { + Name = string.Empty, + LaunchMode = LaunchMode.GuiSelect, + Plugins = [], + LoginCommands = [], + }; + } + else + { + character = FindCharacterLocked( + serverName, + accountName, + characterName); + } + LauncherInstallRecord install = _installRecord + ?? throw new LauncherOperationException(FirstRunRequired); + + string sessionId = ReserveSessionIdLocked(); + var activity = new ManagedActivity( + sessionId, + LauncherActivityKind.Play, + server.Name, + account.Account, + string.IsNullOrWhiteSpace(characterName) ? null : character.Name, + mode, + "Preparing session configuration…"); + _activities.Add(activity); + + request = new StartRequest( + activity, + CloneServer(server), + CloneAccountWithoutCharacters(account), + CloneCharacter(character, mode), + install, + account.Password, + isProbe: false, + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)); + activity.StartCancellation = request.Cancellation; + } + + RaiseStateChanged(); + return StartActivityAsync(request); + } + + public Task ProbeAsync( + string serverName, + string accountName, + CancellationToken cancellationToken = default) + { + LauncherCapability capability = GetProbeCapability(serverName, accountName); + if (!capability.IsAvailable) + { + throw new LauncherOperationException( + capability.Reason ?? "Character refresh is unavailable."); + } + + StartRequest request; + lock (_gate) + { + ThrowIfDisposed(); + + // Repeat the active-account check while reserving the activity so + // two concurrent probes cannot both pass the public capability + // query and then start for the same account. + if (FindActiveActivityLocked(serverName, accountName) is not null) + { + throw new LauncherOperationException( + "A session or character refresh is already running for this account."); + } + + ServerProfile server = FindServerLocked(serverName); + AccountProfile account = FindAccountLocked(serverName, accountName); + LauncherInstallRecord install = _installRecord + ?? throw new LauncherOperationException(FirstRunRequired); + + string sessionId = ReserveSessionIdLocked(); + var activity = new ManagedActivity( + sessionId, + LauncherActivityKind.Probe, + server.Name, + account.Account, + characterName: null, + launchMode: null, + "Preparing character refresh…"); + _activities.Add(activity); + + request = new StartRequest( + activity, + CloneServer(server), + CloneAccountWithoutCharacters(account), + character: null, + install, + account.Password, + isProbe: true, + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)); + activity.StartCancellation = request.Cancellation; + } + + RaiseStateChanged(); + return StartActivityAsync(request); + } + + public async Task StopSessionAsync( + string sessionId, + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + if (timeout < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(timeout)); + } + + ILauncherProcessSupervisor? supervisor; + CancellationTokenSource? startCancellation; + lock (_gate) + { + ThrowIfDisposed(); + ManagedActivity activity = FindActivityLocked(sessionId); + if (!activity.IsActive) + { + return; + } + + activity.State = LauncherActivityState.Stopping; + activity.Status = "Stopping session…"; + supervisor = activity.Supervisor; + startCancellation = activity.StartCancellation; + } + + RaiseStateChanged(); + cancellationToken.ThrowIfCancellationRequested(); + startCancellation?.Cancel(); + + if (supervisor is null) + { + return; + } + + try + { + await Task.Run( + () => supervisor.Stop(timeout), + cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + lock (_gate) + { + ManagedActivity activity = FindActivityLocked(sessionId); + activity.Error = SafeError("Could not stop the session", ex, secret: null); + activity.Status = activity.Error; + } + + RaiseStateChanged(); + throw new LauncherOperationException( + SafeError("Could not stop the session", ex, secret: null)); + } + } + + public void PollStatus() + { + ManagedActivity[] activities; + lock (_gate) + { + ThrowIfDisposed(); + activities = _activities + .Where(activity => activity.StatusSource is not null) + .ToArray(); + } + + bool changed = false; + foreach (ManagedActivity activity in activities) + { + IReadOnlyList events; + try + { + lock (activity.StatusReadGate) + { + events = activity.StatusSource!.ReadNewEvents(); + } + } + catch (Exception ex) + { + lock (_gate) + { + if (_activities.Contains(activity)) + { + activity.Error = SafeError( + "Could not read the host status stream", + ex, + secret: null); + changed = true; + } + } + + continue; + } + + foreach (StatusEvent statusEvent in events) + { + ApplyStatusEvent(activity, statusEvent); + changed = true; + } + } + + if (changed) + { + RaiseStateChanged(); + } + } + + public void ClearFinishedSessions() + { + ManagedActivity[] removed; + lock (_gate) + { + ThrowIfDisposed(); + removed = _activities.Where(activity => !activity.IsActive).ToArray(); + foreach (ManagedActivity activity in removed) + { + _activities.Remove(activity); + } + } + + foreach (ManagedActivity activity in removed) + { + DisposeActivity(activity); + } + + if (removed.Length > 0) + { + RaiseStateChanged(); + } + } + + public void Dispose() + { + ManagedActivity[] activities; + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + activities = _activities.ToArray(); + _activities.Clear(); + } + + foreach (ManagedActivity activity in activities) + { + DisposeActivity(activity); + } + } + + private async Task StartActivityAsync(StartRequest request) + { + try + { + await Task.Run( + () => StartActivityCore(request), + CancellationToken.None) + .ConfigureAwait(false); + lock (_gate) + { + return request.Activity.ToSnapshot(); + } + } + finally + { + request.Password = null; + lock (_gate) + { + if (ReferenceEquals( + request.Activity.StartCancellation, + request.Cancellation)) + { + request.Activity.StartCancellation = null; + } + } + + request.Cancellation.Dispose(); + request.Activity.StartCompleted.Set(); + } + } + + private void StartActivityCore(StartRequest request) + { + ILauncherProcessSupervisor? supervisor = null; + string? password = request.Password; + bool hostStarted = false; + try + { + request.Cancellation.Token.ThrowIfCancellationRequested(); + + UpdateSessionBarrier.SessionLease sessionLease = + _updateSessionBarrier.AcquireSession(); + lock (_gate) + { + request.Activity.UpdateSessionLease = sessionLease; + } + + ComposedSessionConfig composed = request.IsProbe + ? _configService.ComposeProbeAndWrite( + request.Server, + request.Account, + request.Install, + _paths, + request.Activity.SessionId) + : _configService.ComposeAndWrite( + request.Server, + request.Account, + request.Character!, + request.Install, + _paths, + request.Activity.SessionId); + + request.Cancellation.Token.ThrowIfCancellationRequested(); + + supervisor = _supervisorFactory.Create(); + EventHandler stateHandler = + (_, state) => ApplySupervisorState(request.Activity, state); + supervisor.StateChanged += stateHandler; + IStatusEventSource statusSource = + _statusSourceFactory.Create(composed.StatusFilePath); + + lock (_gate) + { + request.Activity.Supervisor = supervisor; + request.Activity.SupervisorStateHandler = stateHandler; + request.Activity.StatusSource = statusSource; + request.Activity.Status = "Starting host process…"; + } + + RaiseStateChanged(); + request.Cancellation.Token.ThrowIfCancellationRequested(); + + LauncherProcessSpec processSpec = request.IsProbe + ? _executables.CreateProbeSpec( + composed.ConfigFilePath, + composed.StderrLogPath) + : _executables.CreatePlaySpec( + request.Activity.LaunchMode!.Value, + composed.ConfigFilePath, + composed.StderrLogPath); + supervisor.Start(processSpec, password); + hostStarted = true; + + request.Password = null; + password = null; + + if (request.Cancellation.IsCancellationRequested) + { + TryStop(supervisor); + request.Cancellation.Token.ThrowIfCancellationRequested(); + } + } + catch (OperationCanceledException) + { + if (supervisor is not null) + { + TryStop(supervisor); + } + + lock (_gate) + { + if (!request.Activity.IsTerminal) + { + request.Activity.State = LauncherActivityState.Cancelled; + request.Activity.Status = "Operation cancelled."; + request.Activity.Error = null; + } + } + + RaiseStateChanged(); + throw; + } + catch (Exception ex) + { + string message = SafeError( + request.IsProbe + ? "Could not refresh characters" + : "Could not launch the client", + ex, + password); + lock (_gate) + { + if (!request.Activity.IsTerminal) + { + request.Activity.State = LauncherActivityState.Failed; + request.Activity.Status = message; + request.Activity.Error = message; + } + } + + RaiseStateChanged(); + throw new LauncherOperationException(message); + } + finally + { + request.Password = null; + if (!hostStarted) + { + ReleaseUpdateSessionLease(request.Activity); + } + } + } + + private void ApplySupervisorState( + ManagedActivity activity, + LauncherSessionState processState) + { + UpdateSessionBarrier.SessionLease? sessionLease = null; + try + { + lock (_gate) + { + if (!_activities.Contains(activity)) + { + return; + } + + switch (processState) + { + case LauncherSessionState.Starting: + if (activity.State == LauncherActivityState.Starting) + { + activity.Status = "Starting host process…"; + } + break; + case LauncherSessionState.Running: + if (activity.State is LauncherActivityState.Starting) + { + activity.State = LauncherActivityState.Running; + activity.Status = "Host process running; waiting for connection…"; + } + break; + case LauncherSessionState.Exited: + activity.ExitCode ??= activity.Supervisor?.ExitCode; + if (!activity.IsTerminal) + { + activity.State = LauncherActivityState.Exited; + activity.Status = activity.HostTerminalStatus + ?? (activity.ExitCode is int code + ? $"Host process exited with code {code}." + : "Host process exited."); + } + else if (activity.State == LauncherActivityState.Exited + && activity.HostTerminalStatus is not null) + { + activity.Status = activity.HostTerminalStatus; + } + sessionLease = activity.UpdateSessionLease; + activity.UpdateSessionLease = null; + break; + } + } + + sessionLease?.Dispose(); + RaiseStateChanged(); + } + catch + { + // Process lifecycle callbacks are observational. A presentation + // subscriber or disposal race must never throw back through the + // supervised child process's Exited event. + } + } + + private void ApplyStatusEvent(ManagedActivity activity, StatusEvent statusEvent) + { + lock (_gate) + { + if (!_activities.Contains(activity)) + { + return; + } + + if (!string.Equals( + statusEvent.SessionId, + activity.SessionId, + StringComparison.Ordinal)) + { + activity.Error = "Ignored a status event for a different session id."; + return; + } + + if (activity.IsTerminal) + { + switch (statusEvent) + { + case ExitedStatusEvent exited: + activity.ExitCode ??= exited.Code; + activity.HostTerminalStatus ??= + $"Exited: {exited.Reason} (code {exited.Code})."; + if (activity.State == LauncherActivityState.Exited) + { + activity.Status = activity.HostTerminalStatus; + } + break; + case CharacterListStatusEvent roster: + ApplyRosterLocked(activity, roster, updateStatus: false); + break; + } + + return; + } + + switch (statusEvent) + { + case StartedStatusEvent: + activity.Status = "Host started."; + break; + case ConnectedStatusEvent: + if (activity.State != LauncherActivityState.Stopping) + { + activity.State = LauncherActivityState.Connected; + } + activity.Status = "Connected; waiting for character roster…"; + break; + case CharacterListStatusEvent roster: + ApplyRosterLocked(activity, roster); + break; + case EnteredWorldStatusEvent enteredWorld: + if (activity.State != LauncherActivityState.Stopping) + { + activity.State = LauncherActivityState.InWorld; + } + activity.Status = $"In world as {enteredWorld.CharacterName}."; + break; + case PluginLoadedStatusEvent loaded: + activity.Status = $"Plugin loaded: {loaded.Plugin}."; + break; + case PluginFailedStatusEvent failed: + activity.Error = $"Plugin failed: {failed.Plugin}: {failed.Error}"; + activity.Status = activity.Error; + break; + case LoginCommandFailedStatusEvent failed: + activity.Error = + $"Login command {failed.CommandIndex} failed: {failed.Error}"; + activity.Status = activity.Error; + break; + case DisconnectedStatusEvent disconnected: + if (activity.State != LauncherActivityState.Stopping) + { + activity.State = LauncherActivityState.Disconnected; + } + activity.Status = $"Disconnected: {disconnected.Reason}."; + break; + case ExitedStatusEvent exited: + activity.State = LauncherActivityState.Exited; + activity.ExitCode = exited.Code; + activity.HostTerminalStatus = + $"Exited: {exited.Reason} (code {exited.Code})."; + activity.Status = activity.HostTerminalStatus; + break; + case MalformedStatusEvent malformed: + activity.Error = $"Malformed host status event: {malformed.Error}"; + break; + case UnknownStatusEvent unknown: + activity.Status = string.IsNullOrWhiteSpace(unknown.E) + ? "Ignored an unreadable host status event." + : $"Ignored unknown host event '{unknown.E}'."; + break; + } + } + } + + private void ApplyRosterLocked( + ManagedActivity activity, + CharacterListStatusEvent roster, + bool updateStatus = true) + { + if (!string.Equals( + roster.AccountName, + activity.AccountName, + StringComparison.Ordinal)) + { + activity.Error = + "Ignored a character roster whose account did not match the launched account."; + return; + } + + try + { + _profileStore.ExecuteTransaction(() => + _profileStore.MergeRoster( + activity.ServerName, + activity.AccountName, + roster.Characters + .Select(character => new CharacterRosterEntry( + character.Id, + character.Name, + character.SecondsGreyedOut)) + .ToArray())); + if (updateStatus) + { + activity.Status = roster.Characters.Count == 1 + ? "Character roster refreshed: 1 character." + : $"Character roster refreshed: {roster.Characters.Count} characters."; + } + } + catch (Exception ex) + { + activity.Error = SafeError( + "Could not save the refreshed character roster", + ex, + secret: null); + if (updateStatus) + { + activity.Status = activity.Error; + } + } + } + + private LauncherServerSnapshot CreateServerSnapshotLocked(ServerProfile server) + { + LauncherAccountSnapshot[] accounts = server.Accounts + .Select(account => CreateAccountSnapshotLocked(server, account)) + .ToArray(); + return new LauncherServerSnapshot( + server.Name, + server.Host, + server.Port, + accounts); + } + + private LauncherAccountSnapshot CreateAccountSnapshotLocked( + ServerProfile server, + AccountProfile account) + { + ManagedActivity? active = FindActiveActivityLocked(server.Name, account.Account); + LauncherCharacterSnapshot[] characters = account.Characters + .Select(character => + { + ManagedActivity? characterActivity = _activities + .LastOrDefault(candidate => + candidate.IsActive + && candidate.Kind == LauncherActivityKind.Play + && string.Equals( + candidate.ServerName, + server.Name, + StringComparison.Ordinal) + && string.Equals( + candidate.AccountName, + account.Account, + StringComparison.Ordinal) + && string.Equals( + candidate.CharacterName, + character.Name, + StringComparison.Ordinal)); + return new LauncherCharacterSnapshot( + server.Name, + account.Account, + character.Name, + character.Id, + character.LaunchMode, + character.Plugins.ToArray(), + character.LoginCommands.ToArray(), + characterActivity is not null, + characterActivity?.Status ?? "Not running"); + }) + .ToArray(); + + return new LauncherAccountSnapshot( + server.Name, + account.Account, + characters, + active is not null, + active?.Status ?? "Idle"); + } + + private void MutateProfiles(Action mutation) + { + ArgumentNullException.ThrowIfNull(mutation); + lock (_gate) + { + ThrowIfDisposed(); + _profileStore.ExecuteTransaction(mutation); + } + + RaiseStateChanged(); + } + + private ServerProfile FindServerLocked(string serverName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(serverName); + return _profileStore.Document.Servers.Find(server => + string.Equals(server.Name, serverName, StringComparison.Ordinal)) + ?? throw new LauncherProfileException($"No server named '{serverName}'."); + } + + private AccountProfile FindAccountLocked(string serverName, string accountName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(accountName); + ServerProfile server = FindServerLocked(serverName); + return server.Accounts.Find(account => + string.Equals(account.Account, accountName, StringComparison.Ordinal)) + ?? throw new LauncherProfileException( + $"No account '{accountName}' on server '{serverName}'."); + } + + private CharacterProfile FindCharacterLocked( + string serverName, + string accountName, + string characterName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(characterName); + AccountProfile account = FindAccountLocked(serverName, accountName); + return account.Characters.Find(character => + string.Equals(character.Name, characterName, StringComparison.Ordinal)) + ?? throw new LauncherProfileException( + $"No character '{characterName}' on account '{accountName}'."); + } + + private ManagedActivity FindActivityLocked(string sessionId) => + _activities.Find(activity => + string.Equals(activity.SessionId, sessionId, StringComparison.Ordinal)) + ?? throw new LauncherOperationException($"No launcher session '{sessionId}'."); + + private ManagedActivity? FindActiveActivityLocked( + string serverName, + string accountName) => + _activities.LastOrDefault(activity => + activity.IsActive + && string.Equals(activity.ServerName, serverName, StringComparison.Ordinal) + && string.Equals(activity.AccountName, accountName, StringComparison.Ordinal)); + + private void EnsureServerIdleLocked(string serverName) + { + if (_activities.Any(activity => + activity.IsActive + && string.Equals(activity.ServerName, serverName, StringComparison.Ordinal))) + { + throw new LauncherOperationException( + "Stop this server's running launcher sessions before editing or removing it."); + } + } + + private void EnsureAccountIdleLocked(string serverName, string accountName) + { + if (FindActiveActivityLocked(serverName, accountName) is not null) + { + throw new LauncherOperationException( + "Stop this account's running launcher session before editing or removing it."); + } + } + + private void EnsureCharacterIdleLocked( + string serverName, + string accountName, + string characterName) + { + if (_activities.Any(activity => + activity.IsActive + && activity.Kind == LauncherActivityKind.Play + && string.Equals(activity.ServerName, serverName, StringComparison.Ordinal) + && string.Equals(activity.AccountName, accountName, StringComparison.Ordinal) + && string.Equals(activity.CharacterName, characterName, StringComparison.Ordinal))) + { + throw new LauncherOperationException( + "Stop this character's running session before editing or removing it."); + } + } + + private string ReserveSessionIdLocked() + { + string sessionId = _sessionIdFactory(); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + if (_activities.Any(activity => string.Equals( + activity.SessionId, + sessionId, + StringComparison.Ordinal))) + { + throw new LauncherOperationException( + $"The launcher generated duplicate session id '{sessionId}'."); + } + + return sessionId; + } + + private static ServerProfile CloneServer(ServerProfile source) => + new() + { + Name = source.Name, + Host = source.Host, + Port = source.Port, + }; + + private static AccountProfile CloneAccountWithoutCharacters(AccountProfile source) => + new() + { + Account = source.Account, + // Composition needs only the public account name. Keep the + // credential exclusively in StartRequest.Password until the one + // supervisor stdin handoff, then clear that reference. + Password = string.Empty, + }; + + private static CharacterProfile CloneCharacter( + CharacterProfile source, + LaunchMode mode) => + new() + { + Name = source.Name, + Id = source.Id, + LaunchMode = mode, + Plugins = [.. source.Plugins], + LoginCommands = [.. source.LoginCommands], + }; + + private static string CreateSessionId() => + $"{DateTimeOffset.UtcNow:yyyyMMddHHmmssfff}-{Guid.NewGuid():N}"; + + private static void TryStop(ILauncherProcessSupervisor supervisor) + { + try + { + supervisor.Stop(TimeSpan.FromSeconds(5)); + } + catch + { + // Cancellation cleanup is best-effort. The activity remains + // visibly Cancelled and never reports a successful launch. + } + } + + private static string SafeError(string prefix, Exception exception, string? secret) + { + string detail = exception.Message; + if (!string.IsNullOrEmpty(secret)) + { + detail = detail.Replace(secret, "[redacted]", StringComparison.Ordinal); + } + + return string.IsNullOrWhiteSpace(detail) + ? prefix + "." + : $"{prefix}: {detail}"; + } + + private void RaiseStateChanged() + { + Delegate[] subscribers = StateChanged?.GetInvocationList() ?? []; + foreach (Delegate subscriber in subscribers) + { + try + { + ((EventHandler)subscriber)(this, EventArgs.Empty); + } + catch + { + // This is an observation seam. A view that is closing or a + // faulty subscriber must not break process/session lifetime. + } + } + } + + private static void DisposeActivity(ManagedActivity activity) + { + activity.StartCancellation?.Cancel(); + activity.StartCompleted.Wait(); + activity.StartCancellation?.Dispose(); + activity.StartCancellation = null; + + if (activity.Supervisor is not null) + { + // Disposal is a process-lifetime transaction: the shared update + // lease remains held until Stop has observed the real child + // terminal (including the post-kill wait). + activity.Supervisor.Stop(TimeSpan.FromSeconds(5)); + if (activity.SupervisorStateHandler is not null) + { + activity.Supervisor.StateChanged -= activity.SupervisorStateHandler; + } + + activity.Supervisor.Dispose(); + activity.Supervisor = null; + } + + ReleaseUpdateSessionLease(activity); + activity.StartCompleted.Dispose(); + } + + private static void ReleaseUpdateSessionLease(ManagedActivity activity) + { + UpdateSessionBarrier.SessionLease? lease = + Interlocked.Exchange(ref activity.UpdateSessionLease, null); + lease?.Dispose(); + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } + + private sealed class ManagedActivity + { + public ManagedActivity( + string sessionId, + LauncherActivityKind kind, + string serverName, + string accountName, + string? characterName, + LaunchMode? launchMode, + string status) + { + SessionId = sessionId; + Kind = kind; + ServerName = serverName; + AccountName = accountName; + CharacterName = characterName; + LaunchMode = launchMode; + Status = status; + CreatedAt = DateTimeOffset.UtcNow; + } + + public string SessionId { get; } + + public LauncherActivityKind Kind { get; } + + public string ServerName { get; } + + public string AccountName { get; } + + public string? CharacterName { get; } + + public LaunchMode? LaunchMode { get; } + + public DateTimeOffset CreatedAt { get; } + + public LauncherActivityState State { get; set; } = LauncherActivityState.Starting; + + public string Status { get; set; } + + public int? ExitCode { get; set; } + + public string? Error { get; set; } + + public string? HostTerminalStatus { get; set; } + + public ILauncherProcessSupervisor? Supervisor { get; set; } + + public EventHandler? SupervisorStateHandler { get; set; } + + public IStatusEventSource? StatusSource { get; set; } + + public CancellationTokenSource? StartCancellation { get; set; } + + public UpdateSessionBarrier.SessionLease? UpdateSessionLease; + + public ManualResetEventSlim StartCompleted { get; } = new(false); + + public object StatusReadGate { get; } = new(); + + public bool IsActive => State is not ( + LauncherActivityState.Exited + or LauncherActivityState.Failed + or LauncherActivityState.Cancelled); + + public bool IsTerminal => !IsActive; + + public LauncherSessionSnapshot ToSnapshot() => + new( + SessionId, + Kind, + ServerName, + AccountName, + CharacterName, + LaunchMode, + State, + Status, + ExitCode, + Error, + CreatedAt); + } + + private sealed class StartRequest( + ManagedActivity activity, + ServerProfile server, + AccountProfile account, + CharacterProfile? character, + LauncherInstallRecord install, + string password, + bool isProbe, + CancellationTokenSource cancellation) + { + public ManagedActivity Activity { get; } = activity; + + public ServerProfile Server { get; } = server; + + public AccountProfile Account { get; } = account; + + public CharacterProfile? Character { get; } = character; + + public LauncherInstallRecord Install { get; } = install; + + public string? Password { get; set; } = password; + + public bool IsProbe { get; } = isProbe; + + public CancellationTokenSource Cancellation { get; } = cancellation; + } +} diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherPlatformCapabilities.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherPlatformCapabilities.cs new file mode 100644 index 00000000..57be9979 --- /dev/null +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherPlatformCapabilities.cs @@ -0,0 +1,83 @@ +using AcDream.Launcher.Core.Profiles; + +namespace AcDream.Launcher.Core.Orchestration; + +public readonly record struct LauncherCapability(bool IsAvailable, string? Reason) +{ + public static LauncherCapability Available { get; } = new(true, null); + + public static LauncherCapability Unavailable(string reason) => + new(false, reason); +} + +/// +/// Immutable platform row selected once at launcher startup. Campaign LA +/// ships the Avalonia launcher, profile editor, probes, and headless sessions +/// on Windows and Linux. Graphical client launches remain Windows-only until +/// Modern Runtime Slice L resumes from its parked L1 checkpoint. +/// +public sealed record LauncherPlatformCapabilities( + bool IsWindows, + bool IsLinux, + bool CanRunHeadless, + bool CanLaunchGraphicalClient, + string PlatformName, + string? GraphicalLaunchDisabledReason) +{ + public const string LinuxGraphicalLaunchDisabledReason = + "GUI launches require the Linux graphical client (Modern Runtime Slice L), " + + "which is parked at L1 and will resume later. The launcher, character " + + "probe, and headless sessions remain available on Linux."; + + public static LauncherPlatformCapabilities Detect() + { + if (OperatingSystem.IsWindows()) + { + return new LauncherPlatformCapabilities( + IsWindows: true, + IsLinux: false, + CanRunHeadless: true, + CanLaunchGraphicalClient: true, + PlatformName: "Windows", + GraphicalLaunchDisabledReason: null); + } + + if (OperatingSystem.IsLinux()) + { + return new LauncherPlatformCapabilities( + IsWindows: false, + IsLinux: true, + CanRunHeadless: true, + CanLaunchGraphicalClient: false, + PlatformName: "Linux", + GraphicalLaunchDisabledReason: LinuxGraphicalLaunchDisabledReason); + } + + return new LauncherPlatformCapabilities( + IsWindows: false, + IsLinux: false, + CanRunHeadless: false, + CanLaunchGraphicalClient: false, + PlatformName: "Unsupported", + GraphicalLaunchDisabledReason: + "Graphical client launches are supported on Windows. Linux support " + + "requires Modern Runtime Slice L."); + } + + public LauncherCapability ForLaunchMode(LaunchMode mode) + { + if (mode == LaunchMode.Headless) + { + return CanRunHeadless + ? LauncherCapability.Available + : LauncherCapability.Unavailable( + "Headless launches are supported only on Windows and Linux."); + } + + return CanLaunchGraphicalClient + ? LauncherCapability.Available + : LauncherCapability.Unavailable( + GraphicalLaunchDisabledReason + ?? "The graphical client is unavailable on this platform."); + } +} diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherStateSnapshot.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherStateSnapshot.cs new file mode 100644 index 00000000..918e1065 --- /dev/null +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherStateSnapshot.cs @@ -0,0 +1,85 @@ +using AcDream.Launcher.Core.Profiles; + +namespace AcDream.Launcher.Core.Orchestration; + +public sealed record LauncherCharacterSnapshot( + string ServerName, + string AccountName, + string Name, + string? Id, + LaunchMode LaunchMode, + IReadOnlyList Plugins, + IReadOnlyList LoginCommands, + bool HasRunningSession, + string SessionStatus); + +/// +/// Password is deliberately absent. The account credential remains reachable +/// only inside and the transient +/// stdin handoff performed by . +/// +public sealed record LauncherAccountSnapshot( + string ServerName, + string AccountName, + IReadOnlyList Characters, + bool HasRunningActivity, + string ActivityStatus); + +public sealed record LauncherServerSnapshot( + string Name, + string Host, + int Port, + IReadOnlyList Accounts); + +public enum LauncherActivityKind +{ + Play, + Probe, +} + +public enum LauncherActivityState +{ + Starting, + Running, + Connected, + InWorld, + Disconnected, + Stopping, + Exited, + Failed, + Cancelled, +} + +public sealed record LauncherSessionSnapshot( + string SessionId, + LauncherActivityKind Kind, + string ServerName, + string AccountName, + string? CharacterName, + LaunchMode? LaunchMode, + LauncherActivityState State, + string Status, + int? ExitCode, + string? Error, + DateTimeOffset CreatedAt) +{ + public bool IsActive => State is not ( + LauncherActivityState.Exited + or LauncherActivityState.Failed + or LauncherActivityState.Cancelled); +} + +public sealed record LauncherStateSnapshot( + IReadOnlyList Servers, + IReadOnlyList Sessions, + LauncherPlatformCapabilities Platform, + bool IsInstallationReady, + string InstallationStatus); + +public sealed class LauncherOperationException : Exception +{ + public LauncherOperationException(string message) + : base(message) + { + } +} diff --git a/src/AcDream.Launcher.Core/Profiles/AccountProfile.cs b/src/AcDream.Launcher.Core/Profiles/AccountProfile.cs new file mode 100644 index 00000000..03b3693c --- /dev/null +++ b/src/AcDream.Launcher.Core/Profiles/AccountProfile.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace AcDream.Launcher.Core.Profiles; + +/// +/// One account under a server, per Campaign LA spec §5. The password is +/// plaintext by explicit user decision +/// (claude-memory/project_launcher_direction.md) — never written +/// anywhere except this file, never logged, never placed in a session +/// config or process argument/environment (see +/// ). +/// +public sealed class AccountProfile +{ + [JsonRequired] + public string Account { get; set; } = string.Empty; + + [JsonRequired] + public string Password { get; set; } = string.Empty; + + public List Characters { get; set; } = []; +} diff --git a/src/AcDream.Launcher.Core/Profiles/CharacterIdFormat.cs b/src/AcDream.Launcher.Core/Profiles/CharacterIdFormat.cs new file mode 100644 index 00000000..573ceef0 --- /dev/null +++ b/src/AcDream.Launcher.Core/Profiles/CharacterIdFormat.cs @@ -0,0 +1,43 @@ +using System.Globalization; + +namespace AcDream.Launcher.Core.Profiles; + +/// +/// Converts between the wire uint character GUID and the +/// launcher-profile hex-string representation ("0x5000000A", +/// matching the convention used throughout the project, e.g. the +/// +Acdream test character's 0x5000000A in CLAUDE.md). +/// +public static class CharacterIdFormat +{ + public static string ToHexString(uint id) => + "0x" + id.ToString("X8", CultureInfo.InvariantCulture); + + /// + /// Parses as a hex character id — the + /// 0x prefix (case-insensitive) is REQUIRED (Campaign LA plan + /// §LA3 review finding F10). Every all-digit id is ALSO a valid hex + /// number (e.g. "12345678"), so accepting a bare unprefixed + /// string as hex silently reinterprets a hand-typed decimal id and + /// selects the wrong character; requiring the prefix makes "this is + /// hex" an explicit, unambiguous signal instead of a guess. + /// + public static bool TryParse(string? text, out uint id) + { + id = 0; + if (string.IsNullOrWhiteSpace(text)) + return false; + + ReadOnlySpan span = text.AsSpan().Trim(); + if (!span.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + return false; + + span = span[2..]; + + return uint.TryParse( + span, + NumberStyles.HexNumber, + CultureInfo.InvariantCulture, + out id); + } +} diff --git a/src/AcDream.Launcher.Core/Profiles/CharacterProfile.cs b/src/AcDream.Launcher.Core/Profiles/CharacterProfile.cs new file mode 100644 index 00000000..18827873 --- /dev/null +++ b/src/AcDream.Launcher.Core/Profiles/CharacterProfile.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; + +namespace AcDream.Launcher.Core.Profiles; + +/// +/// One character row under an account, per Campaign LA spec §5. The +/// / pair is the launcher-maintained +/// cache (fed by status-stream characterList events and roster +/// probes via ); +/// // +/// are user-owned settings that a roster merge must never clobber. +/// +public sealed class CharacterProfile +{ + [JsonRequired] + public string Name { get; set; } = string.Empty; + + /// + /// Hex-formatted character GUID (e.g. "0x5000000A"), matching + /// the convention used elsewhere in the project. Null only for a + /// hand-authored fixture/profile entry that has never been through a + /// roster merge. + /// + public string? Id { get; set; } + + public LaunchMode LaunchMode { get; set; } = LaunchMode.GuiSelect; + + public List Plugins { get; set; } = []; + + public List LoginCommands { get; set; } = []; +} diff --git a/src/AcDream.Launcher.Core/Profiles/CharacterRosterEntry.cs b/src/AcDream.Launcher.Core/Profiles/CharacterRosterEntry.cs new file mode 100644 index 00000000..91b3ea6b --- /dev/null +++ b/src/AcDream.Launcher.Core/Profiles/CharacterRosterEntry.cs @@ -0,0 +1,19 @@ +namespace AcDream.Launcher.Core.Profiles; + +/// +/// One roster row as reported by a host's characterList status +/// event or an on-demand probe launch (Campaign LA spec §3/§6). Mirrors +/// the wire shape of AcDream.Core.Net.Messages.CharacterList.Character +/// (uint Id, string Name, uint SecondsGreyedOut) — Launcher.Core +/// does not reference Core.Net, so this is an independent, intentionally +/// identical shape fed by the status-stream parser +/// (). +/// is carried for completeness but is +/// NEVER persisted into — ACE reports a +/// constant 1 during the pending-delete grace window (a boolean, not a +/// countdown), and the profile schema (§5) has no field for it. +/// +public readonly record struct CharacterRosterEntry( + uint Id, + string Name, + uint SecondsGreyedOut); diff --git a/src/AcDream.Launcher.Core/Profiles/LaunchMode.cs b/src/AcDream.Launcher.Core/Profiles/LaunchMode.cs new file mode 100644 index 00000000..c3c02633 --- /dev/null +++ b/src/AcDream.Launcher.Core/Profiles/LaunchMode.cs @@ -0,0 +1,35 @@ +namespace AcDream.Launcher.Core.Profiles; + +/// +/// Per-character launch behaviour (Campaign LA spec §5). Stored on each +/// and read by +/// to +/// decide the shape of the composed session-config document. +/// +/// +/// Serialized as camelCase text ("gui"/"guiSelect"/ +/// "headless") via the explicit +/// new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, ...) +/// registered in 's serializer options +/// — deliberately NOT a per-type [JsonConverter] attribute, which +/// uses exact member-name casing ("Gui") regardless of the +/// ambient PropertyNamingPolicy. +/// +/// +public enum LaunchMode +{ + /// Launch the graphical client straight into the world as + /// this character. + Gui, + + /// Launch the graphical client but stop at the retail + /// character-select screen — no character selector is sent. This is + /// the default for a character that has never had its launch mode set + /// explicitly. + GuiSelect, + + /// Launch the no-window host running the idle bot + /// policy (enter world, run plugins/login commands, stay until + /// stopped). + Headless, +} diff --git a/src/AcDream.Launcher.Core/Profiles/LauncherProfileDocument.cs b/src/AcDream.Launcher.Core/Profiles/LauncherProfileDocument.cs new file mode 100644 index 00000000..771d3dc2 --- /dev/null +++ b/src/AcDream.Launcher.Core/Profiles/LauncherProfileDocument.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace AcDream.Launcher.Core.Profiles; + +/// +/// Root document for launcher-profiles.json (Campaign LA spec §5) +/// — the launcher's ONLY credential/profile store. Loaded and saved by +/// . +/// +public sealed class LauncherProfileDocument +{ + [JsonRequired] + public int Version { get; set; } = LauncherProfileStore.CurrentVersion; + + public List Servers { get; set; } = []; +} diff --git a/src/AcDream.Launcher.Core/Profiles/LauncherProfileException.cs b/src/AcDream.Launcher.Core/Profiles/LauncherProfileException.cs new file mode 100644 index 00000000..7bef5a6a --- /dev/null +++ b/src/AcDream.Launcher.Core/Profiles/LauncherProfileException.cs @@ -0,0 +1,17 @@ +namespace AcDream.Launcher.Core.Profiles; + +/// Thrown for a malformed launcher-profiles.json document +/// or an invalid CRUD operation against +/// (unknown target, duplicate name, etc.). +public sealed class LauncherProfileException : Exception +{ + public LauncherProfileException(string message) + : base(message) + { + } + + public LauncherProfileException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs b/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs new file mode 100644 index 00000000..f3a1a1b3 --- /dev/null +++ b/src/AcDream.Launcher.Core/Profiles/LauncherProfileStore.cs @@ -0,0 +1,863 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Profiles; + +/// +/// Load/save/CRUD owner for launcher-profiles.json (Campaign LA +/// spec §5) — the launcher's ONLY credential/profile store, and the +/// binding surface the Avalonia UI (slice LA4) mutates directly. +/// +/// +/// A store instance holds the current in-memory +/// after ; every CRUD method mutates that document in +/// place so callers can chain store.AddServer(...); store.Save(); +/// without re-threading a returned document through every call. +/// +/// +public sealed class LauncherProfileStore +{ + internal const int CurrentVersion = 1; + internal const UnixFileMode OwnerOnlyFileMode = + UnixFileMode.UserRead | UnixFileMode.UserWrite; + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + AllowTrailingCommas = false, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = false, + ReadCommentHandling = JsonCommentHandling.Disallow, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + WriteIndented = true, + Converters = + { + new JsonStringEnumConverter( + JsonNamingPolicy.CamelCase, + allowIntegerValues: false), + }, + }; + + public LauncherProfileStore(string filePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + FilePath = Path.GetFullPath(filePath); + Document = new LauncherProfileDocument(); + } + + /// Resolve the store at the canonical location under + /// + /// (%APPDATA%\acdream\launcher-profiles.json / + /// ~/.config/acdream/launcher-profiles.json). + public static LauncherProfileStore ForApplicationPaths(ApplicationPathSet paths) + { + ArgumentNullException.ThrowIfNull(paths); + return new LauncherProfileStore( + Path.Combine(paths.ConfigDirectory, "launcher-profiles.json")); + } + + public string FilePath { get; } + + public LauncherProfileDocument Document { get; private set; } + + /// + /// Loads from . A + /// missing file is not an error — it resolves to a fresh empty + /// document (version 1, no servers), matching a never-launched + /// installation. Returns true when a file was actually read. + /// + public bool Load() + { + // Opportunistic cleanup of a stale ".tmp" left behind by a Save() + // that crashed between creating the temp file and the atomic + // rename (Campaign LA plan §LA3 review finding F4) — a stray + // temp file carries the same plaintext credentials as the real + // store and should not linger. + DeleteStaleTempFile(FilePath + ".tmp"); + + if (!File.Exists(FilePath)) + { + Document = new LauncherProfileDocument(); + return false; + } + + EnsureExistingCredentialFilePermissions(); + + LauncherProfileDocument? document; + using (FileStream stream = File.OpenRead(FilePath)) + { + try + { + document = JsonSerializer.Deserialize( + stream, + SerializerOptions); + } + catch (JsonException ex) + { + throw new LauncherProfileException( + $"'{FilePath}' is not a valid launcher profile document.", + ex); + } + } + + if (document is null) + { + throw new LauncherProfileException($"'{FilePath}' is empty."); + } + + if (document.Version != CurrentVersion) + { + throw new LauncherProfileException( + $"Unsupported launcher-profiles version {document.Version}; " + + $"expected {CurrentVersion}."); + } + + ValidateAndNormalizeDocument(document); + Document = document; + return true; + } + + /// + /// Persists to via a + /// write-then-atomic-rename so a crash mid-write never leaves a + /// truncated credentials file. On Linux, the temp file is created + /// atomically with owner read/write (0600) as its requested creation + /// mode — before its path is observable and before any plaintext + /// credential is serialized into it. The final path retains that mode + /// through the rename (Campaign LA's plaintext-credential decision, + /// spec §5, decisions log). + /// A failure between temp-file creation and the rename deletes the + /// stale temp file rather than leaving it behind. + /// + public void Save() + { + string? directory = Path.GetDirectoryName(FilePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + string tempPath = FilePath + ".tmp"; + DeleteStaleTempFile(tempPath); + try + { + using (FileStream stream = CreateCredentialTempFile(tempPath)) + { + if (OperatingSystem.IsLinux()) + { + // UnixCreateMode is subject to the process umask. It + // guarantees the file is never created with group/other + // access; normalize the owner bits while the still-empty + // file is open so the persisted contract is exactly 0600. + File.SetUnixFileMode(tempPath, OwnerOnlyFileMode); + } + + JsonSerializer.Serialize(stream, Document, SerializerOptions); + } + + if (OperatingSystem.IsLinux() + && File.GetUnixFileMode(tempPath) != OwnerOnlyFileMode) + { + throw new IOException( + "The launcher credential temp file could not be secured to mode 0600."); + } + + File.Move(tempPath, FilePath, overwrite: true); + } + catch + { + DeleteStaleTempFile(tempPath); + throw; + } + + } + + /// + /// Builds the exact options used for the plaintext-credential temp + /// file. makes creation atomic and + /// refuses to follow an existing stale or raced path. On Linux, + /// supplies 0600 to + /// the OS create operation itself, eliminating the observable + /// create-then-chmod window. Windows leaves UnixCreateMode unset and + /// therefore retains its normal user-profile ACL behavior. + /// + internal static FileStreamOptions CreateCredentialTempFileOptions() + { + var options = new FileStreamOptions + { + Mode = FileMode.CreateNew, + Access = FileAccess.Write, + Share = FileShare.None, + }; + + if (OperatingSystem.IsLinux()) + { + options.UnixCreateMode = OwnerOnlyFileMode; + } + + return options; + } + + internal static FileStream CreateCredentialTempFile(string tempPath) => + new(tempPath, CreateCredentialTempFileOptions()); + + private static void DeleteStaleTempFile(string tempPath) + { + try + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + catch + { + // Best-effort cleanup only — the caller's own exception (a + // failed Save()) or the fresh Load() already in progress is + // what matters; a cleanup failure must not mask either. + } + } + + // --- Server CRUD ----------------------------------------------- + + public ServerProfile AddServer(string name, string host, int port) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(host); + RequireValidPort(port); + + if (FindServer(name) is not null) + { + throw new LauncherProfileException( + $"A server named '{name}' already exists."); + } + + var server = new ServerProfile { Name = name, Host = host, Port = port }; + Document.Servers.Add(server); + return server; + } + + public void EditServer( + string name, + string? newName = null, + string? newHost = null, + int? newPort = null) + { + ServerProfile server = FindServerOrThrow(name); + + if (newName is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(newName); + if (!string.Equals(newName, server.Name, StringComparison.Ordinal) + && FindServer(newName) is not null) + { + throw new LauncherProfileException( + $"A server named '{newName}' already exists."); + } + } + + if (newHost is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(newHost); + } + + if (newPort is not null) + { + RequireValidPort(newPort.Value); + } + + server.Name = newName ?? server.Name; + server.Host = newHost ?? server.Host; + server.Port = newPort ?? server.Port; + } + + public void RemoveServer(string name) + { + ServerProfile server = FindServerOrThrow(name); + Document.Servers.Remove(server); + } + + // --- Account CRUD ------------------------------------------------ + + public AccountProfile AddAccount(string serverName, string account, string password) + { + ArgumentException.ThrowIfNullOrWhiteSpace(account); + ArgumentNullException.ThrowIfNull(password); + ServerProfile server = FindServerOrThrow(serverName); + + if (FindAccount(server, account) is not null) + { + throw new LauncherProfileException( + $"Account '{account}' already exists on server '{serverName}'."); + } + + var profile = new AccountProfile { Account = account, Password = password }; + server.Accounts.Add(profile); + return profile; + } + + public void EditAccount( + string serverName, + string account, + string? newAccount = null, + string? newPassword = null) + { + ServerProfile server = FindServerOrThrow(serverName); + AccountProfile profile = FindAccountOrThrow(server, account); + + if (newAccount is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(newAccount); + if (!string.Equals(newAccount, profile.Account, StringComparison.Ordinal) + && FindAccount(server, newAccount) is not null) + { + throw new LauncherProfileException( + $"Account '{newAccount}' already exists on server '{serverName}'."); + } + } + + profile.Account = newAccount ?? profile.Account; + + if (newPassword is not null) + { + profile.Password = newPassword; + } + } + + public void RemoveAccount(string serverName, string account) + { + ServerProfile server = FindServerOrThrow(serverName); + AccountProfile profile = FindAccountOrThrow(server, account); + server.Accounts.Remove(profile); + } + + // --- Character CRUD / user-owned settings -------------------------- + + /// + /// Adds a manually configured character row. Normal operation discovers + /// characters through , but LA4's full in-UI + /// CRUD contract also lets a user create a cached row before a successful + /// probe (for example, to launch by a known character name while a server + /// is temporarily unavailable). A later roster merge remains + /// authoritative for the id/name pair and preserves these user settings. + /// + public CharacterProfile AddCharacter( + string serverName, + string account, + string characterName, + string? id = null, + LaunchMode launchMode = LaunchMode.GuiSelect, + IReadOnlyList? plugins = null, + IReadOnlyList? loginCommands = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(characterName); + RequireValidLaunchMode(launchMode); + ValidateStringList(plugins, "plugin", requireUnique: true); + ValidateStringList(loginCommands, "login command", requireUnique: false); + ServerProfile server = FindServerOrThrow(serverName); + AccountProfile profile = FindAccountOrThrow(server, account); + + if (FindCharacter(profile, characterName) is not null) + { + throw new LauncherProfileException( + $"Character '{characterName}' already exists on account '{account}'."); + } + + string? normalizedId = NormalizeCharacterId(id); + if (normalizedId is not null + && profile.Characters.Any(character => CharacterIdsEqual(character.Id, normalizedId))) + { + throw new LauncherProfileException( + $"Character id '{normalizedId}' already exists on account '{account}'."); + } + + var character = new CharacterProfile + { + Name = characterName, + Id = normalizedId, + LaunchMode = launchMode, + Plugins = plugins is null ? [] : [.. plugins], + LoginCommands = loginCommands is null ? [] : [.. loginCommands], + }; + profile.Characters.Add(character); + return character; + } + + /// + /// Edits the identity cache and/or user-owned settings of an existing + /// character row. Passing an empty clears a + /// manually entered id so launches fall back to the character name. + /// + public void EditCharacter( + string serverName, + string account, + string characterName, + LaunchMode? launchMode = null, + IReadOnlyList? plugins = null, + IReadOnlyList? loginCommands = null, + string? newName = null, + string? newId = null) + { + ServerProfile server = FindServerOrThrow(serverName); + AccountProfile profile = FindAccountOrThrow(server, account); + CharacterProfile character = FindCharacterOrThrow(profile, characterName); + + string? normalizedId = null; + + if (newName is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(newName); + if (!string.Equals(newName, character.Name, StringComparison.Ordinal) + && FindCharacter(profile, newName) is not null) + { + throw new LauncherProfileException( + $"Character '{newName}' already exists on account '{account}'."); + } + } + + if (newId is not null) + { + normalizedId = NormalizeCharacterId(newId); + if (normalizedId is not null + && profile.Characters.Any(candidate => + !ReferenceEquals(candidate, character) + && CharacterIdsEqual(candidate.Id, normalizedId))) + { + throw new LauncherProfileException( + $"Character id '{normalizedId}' already exists on account '{account}'."); + } + } + + if (launchMode is not null) + { + RequireValidLaunchMode(launchMode.Value); + } + + ValidateStringList(plugins, "plugin", requireUnique: true); + ValidateStringList(loginCommands, "login command", requireUnique: false); + + character.Name = newName ?? character.Name; + if (newId is not null) + { + character.Id = normalizedId; + } + + if (launchMode is not null) + { + character.LaunchMode = launchMode.Value; + } + + if (plugins is not null) + { + character.Plugins = [.. plugins]; + } + + if (loginCommands is not null) + { + character.LoginCommands = [.. loginCommands]; + } + } + + private void EnsureExistingCredentialFilePermissions() + { + if (!OperatingSystem.IsLinux()) + { + return; + } + + try + { + UnixFileMode mode = File.GetUnixFileMode(FilePath); + if (mode != OwnerOnlyFileMode) + { + File.SetUnixFileMode(FilePath, OwnerOnlyFileMode); + mode = File.GetUnixFileMode(FilePath); + } + + if (mode != OwnerOnlyFileMode) + { + throw new IOException($"Mode remained {mode} after normalization."); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw new LauncherProfileException( + $"'{FilePath}' could not be secured to owner-only mode 0600.", + ex); + } + } + + /// + /// Applies one profile mutation and its atomic file replacement as a + /// single in-memory/on-disk transaction. Any validation or I/O failure + /// restores the exact pre-mutation document, including credentials. + /// + public void ExecuteTransaction(Action mutation) + { + ArgumentNullException.ThrowIfNull(mutation); + LauncherProfileDocument before = CloneDocument(Document); + try + { + mutation(); + ValidateAndNormalizeDocument(Document); + Save(); + } + catch + { + Document = before; + throw; + } + } + + public void RemoveCharacter( + string serverName, + string account, + string characterName) + { + ServerProfile server = FindServerOrThrow(serverName); + AccountProfile profile = FindAccountOrThrow(server, account); + CharacterProfile character = FindCharacterOrThrow(profile, characterName); + profile.Characters.Remove(character); + } + + /// + /// Folds a reported character roster into an account's + /// (Campaign LA spec §3/§5/ + /// §6): every roster entry either updates the name of an existing + /// row (matched by ) while + /// PRESERVING that row's user settings (, + /// , + /// ), or is inserted as a + /// new row with default settings (, + /// no plugins, no login commands). Existing rows absent from the + /// roster are RETAINED unchanged — they may simply be pending-delete + /// (ACE keeps deleted characters queryable during the grace window) + /// or the roster snapshot may be partial; this store never deletes a + /// character row on the caller's behalf. + /// + public void MergeRoster( + string serverName, + string account, + IReadOnlyList roster) + { + ArgumentNullException.ThrowIfNull(roster); + ServerProfile server = FindServerOrThrow(serverName); + AccountProfile profile = FindAccountOrThrow(server, account); + + var rosterIds = new HashSet(); + var rosterNames = new HashSet(StringComparer.Ordinal); + foreach (CharacterRosterEntry entry in roster) + { + if (entry.Id == 0) + { + throw new LauncherProfileException("A roster character id cannot be zero."); + } + + ArgumentException.ThrowIfNullOrWhiteSpace(entry.Name); + if (!rosterIds.Add(entry.Id) || !rosterNames.Add(entry.Name)) + { + throw new LauncherProfileException( + "The reported character roster contains a duplicate id or name."); + } + } + + foreach (CharacterRosterEntry entry in roster) + { + string idText = CharacterIdFormat.ToHexString(entry.Id); + + CharacterProfile[] matches = profile.Characters + .Where(character => + (CharacterIdFormat.TryParse(character.Id, out uint existingId) + && existingId == entry.Id) + || string.Equals(character.Name, entry.Name, StringComparison.Ordinal)) + .ToArray(); + CharacterProfile? existing = matches.FirstOrDefault(character => + CharacterIdFormat.TryParse(character.Id, out uint existingId) + && existingId == entry.Id) + ?? matches.FirstOrDefault(); + + if (existing is not null) + { + existing.Id = idText; + existing.Name = entry.Name; + foreach (CharacterProfile duplicate in matches) + { + if (!ReferenceEquals(duplicate, existing)) + { + profile.Characters.Remove(duplicate); + } + } + continue; + } + + profile.Characters.Add(new CharacterProfile + { + Id = idText, + Name = entry.Name, + LaunchMode = LaunchMode.GuiSelect, + Plugins = [], + LoginCommands = [], + }); + } + } + + // --- Lookups ------------------------------------------------------- + + private ServerProfile? FindServer(string name) => + Document.Servers.Find( + server => string.Equals(server.Name, name, StringComparison.Ordinal)); + + private ServerProfile FindServerOrThrow(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return FindServer(name) + ?? throw new LauncherProfileException($"No server named '{name}'."); + } + + private static AccountProfile? FindAccount(ServerProfile server, string account) => + server.Accounts.Find( + candidate => string.Equals(candidate.Account, account, StringComparison.Ordinal)); + + private static AccountProfile FindAccountOrThrow(ServerProfile server, string account) + { + ArgumentException.ThrowIfNullOrWhiteSpace(account); + return FindAccount(server, account) + ?? throw new LauncherProfileException( + $"No account '{account}' on server '{server.Name}'."); + } + + private static CharacterProfile? FindCharacter( + AccountProfile profile, + string characterName) => + profile.Characters.Find( + character => string.Equals( + character.Name, + characterName, + StringComparison.Ordinal)); + + private static CharacterProfile FindCharacterOrThrow( + AccountProfile profile, + string characterName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(characterName); + return FindCharacter(profile, characterName) + ?? throw new LauncherProfileException( + $"No character '{characterName}' on account '{profile.Account}'."); + } + + private static string? NormalizeCharacterId(string? id) + { + if (string.IsNullOrWhiteSpace(id)) + { + return null; + } + + if (!CharacterIdFormat.TryParse(id, out uint parsed) || parsed == 0) + { + throw new LauncherProfileException( + "Character id must be a non-zero hexadecimal value with a 0x prefix."); + } + + return CharacterIdFormat.ToHexString(parsed); + } + + private static bool CharacterIdsEqual(string? left, string? right) => + CharacterIdFormat.TryParse(left, out uint leftId) + && CharacterIdFormat.TryParse(right, out uint rightId) + && leftId == rightId; + + private static LauncherProfileDocument CloneDocument( + LauncherProfileDocument source) => + new() + { + Version = source.Version, + Servers = source.Servers.Select(server => new ServerProfile + { + Name = server.Name, + Host = server.Host, + Port = server.Port, + Accounts = server.Accounts.Select(account => new AccountProfile + { + Account = account.Account, + Password = account.Password, + Characters = account.Characters.Select(character => new CharacterProfile + { + Name = character.Name, + Id = character.Id, + LaunchMode = character.LaunchMode, + Plugins = [.. character.Plugins], + LoginCommands = [.. character.LoginCommands], + }).ToList(), + }).ToList(), + }).ToList(), + }; + + private static void ValidateAndNormalizeDocument(LauncherProfileDocument document) + { + if (document.Servers is null) + { + throw new LauncherProfileException("The servers collection cannot be null."); + } + + var serverNames = new HashSet(StringComparer.Ordinal); + var normalizedIds = new List<(CharacterProfile Character, uint Id)>(); + foreach (ServerProfile? server in document.Servers) + { + if (server is null) + { + throw new LauncherProfileException("A server entry cannot be null."); + } + + RequireLoadedText(server.Name, "server name"); + RequireLoadedText(server.Host, $"host for server '{server.Name}'"); + RequireValidPort(server.Port); + if (!serverNames.Add(server.Name)) + { + throw new LauncherProfileException( + $"A server named '{server.Name}' appears more than once."); + } + + if (server.Accounts is null) + { + throw new LauncherProfileException( + $"The accounts collection for server '{server.Name}' cannot be null."); + } + + var accountNames = new HashSet(StringComparer.Ordinal); + foreach (AccountProfile? account in server.Accounts) + { + if (account is null) + { + throw new LauncherProfileException( + $"A null account appears under server '{server.Name}'."); + } + + RequireLoadedText(account.Account, "account name"); + if (account.Password is null) + { + throw new LauncherProfileException( + $"Password for account '{account.Account}' cannot be null."); + } + + if (!accountNames.Add(account.Account)) + { + throw new LauncherProfileException( + $"Account '{account.Account}' appears more than once on server '{server.Name}'."); + } + + if (account.Characters is null) + { + throw new LauncherProfileException( + $"The characters collection for account '{account.Account}' cannot be null."); + } + + var characterNames = new HashSet(StringComparer.Ordinal); + var characterIds = new HashSet(); + foreach (CharacterProfile? character in account.Characters) + { + if (character is null) + { + throw new LauncherProfileException( + $"A null character appears under account '{account.Account}'."); + } + + RequireLoadedText(character.Name, "character name"); + if (!characterNames.Add(character.Name)) + { + throw new LauncherProfileException( + $"Character '{character.Name}' appears more than once on account '{account.Account}'."); + } + + RequireValidLaunchMode(character.LaunchMode); + if (character.Id is not null) + { + if (!CharacterIdFormat.TryParse(character.Id, out uint id) || id == 0) + { + throw new LauncherProfileException( + $"Character '{character.Name}' has an invalid id '{character.Id}'."); + } + + if (!characterIds.Add(id)) + { + throw new LauncherProfileException( + $"Character id '{character.Id}' appears more than once on account '{account.Account}'."); + } + + normalizedIds.Add((character, id)); + } + + if (character.Plugins is null || character.LoginCommands is null) + { + throw new LauncherProfileException( + $"Character '{character.Name}' has a null settings collection."); + } + + ValidateStringList(character.Plugins, "plugin", requireUnique: true); + ValidateStringList( + character.LoginCommands, + "login command", + requireUnique: false); + } + } + } + + foreach ((CharacterProfile character, uint id) in normalizedIds) + { + character.Id = CharacterIdFormat.ToHexString(id); + } + } + + private static void ValidateStringList( + IReadOnlyList? values, + string valueName, + bool requireUnique) + { + if (values is null) + { + return; + } + + HashSet? seen = requireUnique + ? new HashSet(StringComparer.Ordinal) + : null; + foreach (string? value in values) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new LauncherProfileException( + $"A {valueName} cannot be null or whitespace."); + } + + if (seen is not null && !seen.Add(value)) + { + throw new LauncherProfileException( + $"The {valueName} '{value}' appears more than once."); + } + } + } + + private static void RequireLoadedText(string? value, string field) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new LauncherProfileException($"The {field} cannot be null or whitespace."); + } + } + + private static void RequireValidLaunchMode(LaunchMode mode) + { + if (!Enum.IsDefined(mode)) + { + throw new LauncherProfileException($"Launch mode '{mode}' is not supported."); + } + } + + private static void RequireValidPort(int port) + { + if (port is < 1 or > 65535) + { + throw new LauncherProfileException( + $"Port {port} is outside the valid 1-65535 range."); + } + } +} diff --git a/src/AcDream.Launcher.Core/Profiles/ServerProfile.cs b/src/AcDream.Launcher.Core/Profiles/ServerProfile.cs new file mode 100644 index 00000000..3a5f0388 --- /dev/null +++ b/src/AcDream.Launcher.Core/Profiles/ServerProfile.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace AcDream.Launcher.Core.Profiles; + +/// One server entry, per Campaign LA spec §5 (manual add — no +/// published server-list import this campaign). +public sealed class ServerProfile +{ + [JsonRequired] + public string Name { get; set; } = string.Empty; + + [JsonRequired] + public string Host { get; set; } = string.Empty; + + [JsonRequired] + public int Port { get; set; } + + public List Accounts { get; set; } = []; +} diff --git a/src/AcDream.Launcher.Core/Status/StatusEvent.cs b/src/AcDream.Launcher.Core/Status/StatusEvent.cs new file mode 100644 index 00000000..6b63bfbe --- /dev/null +++ b/src/AcDream.Launcher.Core/Status/StatusEvent.cs @@ -0,0 +1,141 @@ +namespace AcDream.Launcher.Core.Status; + +/// +/// One parsed line of a host's status.jsonl stream (Campaign LA +/// spec §6). Every event carries the versioned envelope +/// (v/e/t/sessionId) plus its own typed +/// payload. See for the wire shape and +/// for the +/// incremental reader that produces these. +/// +public abstract record StatusEvent +{ + public required int V { get; init; } + + public required string E { get; init; } + + public required DateTimeOffset T { get; init; } + + public required string SessionId { get; init; } +} + +public sealed record StartedStatusEvent : StatusEvent; + +public sealed record ConnectedStatusEvent : StatusEvent; + +public readonly record struct StatusCharacterEntry( + uint Id, + string Name, + uint SecondsGreyedOut); + +public sealed record CharacterListStatusEvent : StatusEvent +{ + public required string AccountName { get; init; } + + public required int SlotCount { get; init; } + + public required IReadOnlyList Characters { get; init; } +} + +public sealed record EnteredWorldStatusEvent : StatusEvent +{ + public required uint CharacterId { get; init; } + + public required string CharacterName { get; init; } +} + +/// +/// Campaign CC CC2: the Ok reply to an outbound CharacterCreate (opcode +/// 0xF656). / mirror the shared +/// 0xF643 CharGenVerificationResponse Ok identity payload's own +/// field names — deliberately distinct from 's +/// characterId/characterName, since retail logs a freshly +/// created character straight in without a fresh characterList, so +/// this event can precede an for the +/// same character rather than replace it. +/// +public sealed record CharacterCreatedStatusEvent : StatusEvent +{ + public required uint Guid { get; init; } + + public required string Name { get; init; } +} + +/// +/// Campaign CC CC2: a non-Ok reply to an outbound CharacterCreate. +/// is the raw wire +/// CharGenVerificationResponse.Code value; is +/// that code's enum member name (e.g. "NameInUse"); +/// is the ATTEMPTED character name. The enum member +/// rode the name key until the CC2 review (F4) — same key, +/// different meaning than characterCreated.name — renamed before +/// any consumer shipped. +/// +public sealed record CreationFailedStatusEvent : StatusEvent +{ + public required uint Code { get; init; } + + public required string Reason { get; init; } + + public required string Name { get; init; } +} + +public sealed record PluginLoadedStatusEvent : StatusEvent +{ + public required string Plugin { get; init; } +} + +public sealed record PluginFailedStatusEvent : StatusEvent +{ + public required string Plugin { get; init; } + + public required string Error { get; init; } +} + +public sealed record LoginCommandFailedStatusEvent : StatusEvent +{ + public required int CommandIndex { get; init; } + + public required string Command { get; init; } + + public required string Error { get; init; } +} + +public sealed record DisconnectedStatusEvent : StatusEvent +{ + public required string Reason { get; init; } +} + +public sealed record ExitedStatusEvent : StatusEvent +{ + public required int Code { get; init; } + + public required string Reason { get; init; } +} + +/// +/// A JSON-object status line whose non-empty string e value this +/// reader does not recognize. The tailer never throws on an unrecognized +/// event — an older launcher reading a newer host's stream +/// degrades to seeing rows instead of +/// crashing. +/// +public sealed record UnknownStatusEvent : StatusEvent +{ + public required string RawJson { get; init; } +} + +/// +/// A complete JSON value that is not an object, an object without a +/// usable event name, or a known event whose pinned v1 envelope/payload +/// does not match its expected shape. Distinguished from +/// (Campaign +/// LA plan §LA3 review finding F12) so a launcher can tell "a newer/older +/// host sent an event I've never heard of" apart from "a host I recognize +/// sent me garbage for an event I do know" — the two cases call for +/// different diagnostics. The tailer never throws for either case. +/// +public sealed record MalformedStatusEvent : StatusEvent +{ + public required string Error { get; init; } +} diff --git a/src/AcDream.Launcher.Core/Status/StatusEventParser.cs b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs new file mode 100644 index 00000000..845d647c --- /dev/null +++ b/src/AcDream.Launcher.Core/Status/StatusEventParser.cs @@ -0,0 +1,459 @@ +using System.Text.Json; + +namespace AcDream.Launcher.Core.Status; + +/// +/// Parses one status.jsonl line (Campaign LA spec §6) into a typed +/// . Wire shape: every line is a flat JSON +/// object carrying the envelope (v, e, t, +/// sessionId) alongside that event's own fields — e.g. +/// {"v":1,"e":"characterList","t":"...","sessionId":"...", +/// "accountName":"...","slotCount":6,"characters":[...]}. +/// +/// +/// Never throws: a null/blank/malformed-JSON line, a complete JSON value +/// with a non-object root, an unrecognized e value, or a recognized +/// e whose envelope/payload does not match the pinned v1 shape all +/// degrade to a typed event ( or +/// ) rather than an exception. A launcher +/// must keep tailing a session's status stream even against a host running a +/// newer/older wire version or a host that writes a bad line. +/// +/// +public static class StatusEventParser +{ + public static StatusEvent Parse(string line) + { + if (string.IsNullOrWhiteSpace(line)) + { + return UnknownEvent(line ?? string.Empty); + } + + JsonDocument document; + try + { + document = JsonDocument.Parse(line); + } + catch (JsonException) + { + return UnknownEvent(line); + } + + using (document) + { + JsonElement root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return MalformedEvent( + v: 0, + e: string.Empty, + t: default, + sessionId: string.Empty, + "status event root is not a JSON object."); + } + + if (!TryGetEventName(root, out string e, out string eventNameError)) + { + return MalformedEvent( + GetInt32OrDefault(root, "v"), + e, + GetDateTimeOffsetOrDefault(root, "t"), + GetStringOrDefault(root, "sessionId"), + eventNameError); + } + + // A genuinely unknown event name is the forward-compatibility + // case. Retain its best-effort envelope and raw JSON without + // imposing this launcher's known-event envelope/payload schema. + if (!IsKnownEventName(e)) + { + return new UnknownStatusEvent + { + V = GetInt32OrDefault(root, "v"), + E = e, + T = GetDateTimeOffsetOrDefault(root, "t"), + SessionId = GetStringOrDefault(root, "sessionId"), + RawJson = line, + }; + } + + try + { + // The pinned v1 envelope applies to every known event, + // including payload-free started/connected rows. Defaulting + // malformed fields would turn corrupt or cross-version input + // into an apparently valid typed event. + int v = RequireVersionOne(root); + DateTimeOffset t = RequireUtcTimestamp(root, "t"); + string sessionId = RequireNonEmptyString(root, "sessionId"); + + return e switch + { + "started" => + new StartedStatusEvent { V = v, E = e, T = t, SessionId = sessionId }, + "connected" => + new ConnectedStatusEvent { V = v, E = e, T = t, SessionId = sessionId }, + "characterList" => + ParseCharacterList(root, v, e, t, sessionId), + "enteredWorld" => + ParseEnteredWorld(root, v, e, t, sessionId), + "pluginLoaded" => + ParsePluginLoaded(root, v, e, t, sessionId), + "pluginFailed" => + ParsePluginFailed(root, v, e, t, sessionId), + "loginCommandFailed" => + ParseLoginCommandFailed(root, v, e, t, sessionId), + "characterCreated" => + ParseCharacterCreated(root, v, e, t, sessionId), + "creationFailed" => + ParseCreationFailed(root, v, e, t, sessionId), + "disconnected" => + ParseDisconnected(root, v, e, t, sessionId), + "exited" => + ParseExited(root, v, e, t, sessionId), + _ => throw new InvalidOperationException("known event dispatch is incomplete."), + }; + } + catch (Exception ex) when (ex is FormatException or InvalidOperationException) + { + return MalformedEvent( + GetInt32OrDefault(root, "v"), + e, + GetDateTimeOffsetOrDefault(root, "t"), + GetStringOrDefault(root, "sessionId"), + ex.Message); + } + } + } + + private static bool IsKnownEventName(string eventName) => + eventName is + "started" or + "connected" or + "characterList" or + "enteredWorld" or + "pluginLoaded" or + "pluginFailed" or + "loginCommandFailed" or + "characterCreated" or + "creationFailed" or + "disconnected" or + "exited"; + + private static bool TryGetEventName( + JsonElement root, + out string eventName, + out string error) + { + if (!root.TryGetProperty("e", out JsonElement element)) + { + eventName = string.Empty; + error = "status event is missing 'e'."; + return false; + } + + if (element.ValueKind != JsonValueKind.String) + { + eventName = string.Empty; + error = "status event field 'e' is not a string."; + return false; + } + + eventName = element.GetString() ?? string.Empty; + if (string.IsNullOrWhiteSpace(eventName)) + { + error = "status event field 'e' is empty."; + return false; + } + + error = string.Empty; + return true; + } + + private static UnknownStatusEvent UnknownEvent(string rawLine) => + new() + { + V = 0, + E = string.Empty, + T = default, + SessionId = string.Empty, + RawJson = rawLine, + }; + + private static MalformedStatusEvent MalformedEvent( + int v, + string e, + DateTimeOffset t, + string sessionId, + string error) => + new() + { + V = v, + E = e, + T = t, + SessionId = sessionId, + Error = error, + }; + + private static StatusEvent ParseCharacterList( + JsonElement root, + int v, + string e, + DateTimeOffset t, + string sessionId) + { + string accountName = RequireString(root, "accountName"); + int slotCount = RequireInt32(root, "slotCount"); + JsonElement charactersElement = RequireProperty(root, "characters"); + + var characters = new List(); + foreach (JsonElement item in charactersElement.EnumerateArray()) + { + uint id = RequireUInt32(item, "id"); + string name = RequireString(item, "name"); + uint secondsGreyedOut = RequireUInt32(item, "secondsGreyedOut"); + characters.Add(new StatusCharacterEntry(id, name, secondsGreyedOut)); + } + + return new CharacterListStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + AccountName = accountName, + SlotCount = slotCount, + Characters = characters, + }; + } + + private static StatusEvent ParseEnteredWorld( + JsonElement root, + int v, + string e, + DateTimeOffset t, + string sessionId) => + new EnteredWorldStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + CharacterId = RequireUInt32(root, "characterId"), + CharacterName = RequireString(root, "characterName"), + }; + + private static StatusEvent ParseCharacterCreated( + JsonElement root, + int v, + string e, + DateTimeOffset t, + string sessionId) => + new CharacterCreatedStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + Guid = RequireUInt32(root, "guid"), + Name = RequireString(root, "name"), + }; + + private static StatusEvent ParseCreationFailed( + JsonElement root, + int v, + string e, + DateTimeOffset t, + string sessionId) => + new CreationFailedStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + Code = RequireUInt32(root, "code"), + Reason = RequireString(root, "reason"), + Name = RequireString(root, "name"), + }; + + private static StatusEvent ParsePluginLoaded( + JsonElement root, + int v, + string e, + DateTimeOffset t, + string sessionId) => + new PluginLoadedStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + Plugin = RequireString(root, "plugin"), + }; + + private static StatusEvent ParsePluginFailed( + JsonElement root, + int v, + string e, + DateTimeOffset t, + string sessionId) => + new PluginFailedStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + Plugin = RequireString(root, "plugin"), + Error = RequireString(root, "error"), + }; + + private static StatusEvent ParseDisconnected( + JsonElement root, + int v, + string e, + DateTimeOffset t, + string sessionId) => + new DisconnectedStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + Reason = RequireString(root, "reason"), + }; + + private static StatusEvent ParseLoginCommandFailed( + JsonElement root, + int v, + string e, + DateTimeOffset t, + string sessionId) + { + int commandIndex = RequireInt32(root, "commandIndex"); + if (commandIndex < 0) + { + throw new FormatException( + "status event field 'commandIndex' is negative."); + } + + return new LoginCommandFailedStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + CommandIndex = commandIndex, + Command = RequireString(root, "command"), + Error = RequireString(root, "error"), + }; + } + + private static StatusEvent ParseExited( + JsonElement root, + int v, + string e, + DateTimeOffset t, + string sessionId) => + new ExitedStatusEvent + { + V = v, + E = e, + T = t, + SessionId = sessionId, + Code = RequireInt32(root, "code"), + Reason = RequireString(root, "reason"), + }; + + private static int GetInt32OrDefault(JsonElement root, string name) => + root.TryGetProperty(name, out JsonElement element) + && element.ValueKind == JsonValueKind.Number + && element.TryGetInt32(out int value) + ? value + : 0; + + private static string GetStringOrDefault(JsonElement root, string name) => + root.TryGetProperty(name, out JsonElement element) + && element.ValueKind == JsonValueKind.String + ? element.GetString() ?? string.Empty + : string.Empty; + + private static DateTimeOffset GetDateTimeOffsetOrDefault( + JsonElement root, + string name) => + root.TryGetProperty(name, out JsonElement element) + && element.ValueKind == JsonValueKind.String + && element.TryGetDateTimeOffset(out DateTimeOffset value) + && value.Offset == TimeSpan.Zero + ? value + : default; + + private static JsonElement RequireProperty(JsonElement root, string name) => + root.TryGetProperty(name, out JsonElement element) + ? element + : throw new FormatException($"status event is missing '{name}'."); + + private static string RequireString(JsonElement root, string name) + { + JsonElement element = RequireProperty(root, name); + return element.ValueKind == JsonValueKind.String + ? element.GetString() ?? string.Empty + : throw new FormatException($"status event field '{name}' is not a string."); + } + + private static int RequireInt32(JsonElement root, string name) + { + JsonElement element = RequireProperty(root, name); + return element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out int value) + ? value + : throw new FormatException($"status event field '{name}' is not an integer."); + } + + private static int RequireVersionOne(JsonElement root) + { + int version = RequireInt32(root, "v"); + return version == 1 + ? version + : throw new FormatException( + $"status event version is {version}; expected 1."); + } + + private static string RequireNonEmptyString(JsonElement root, string name) + { + string value = RequireString(root, name); + return !string.IsNullOrWhiteSpace(value) + ? value + : throw new FormatException($"status event field '{name}' is empty."); + } + + private static DateTimeOffset RequireUtcTimestamp(JsonElement root, string name) + { + JsonElement element = RequireProperty(root, name); + if (element.ValueKind != JsonValueKind.String) + { + throw new FormatException( + $"status event field '{name}' is not an ISO-8601 UTC string."); + } + + string text = element.GetString() ?? string.Empty; + bool hasExplicitUtcOffset = text.EndsWith('Z') + || text.EndsWith("+00:00", StringComparison.Ordinal); + if (!hasExplicitUtcOffset + || !element.TryGetDateTimeOffset(out DateTimeOffset value) + || value.Offset != TimeSpan.Zero) + { + throw new FormatException( + $"status event field '{name}' is not an ISO-8601 UTC timestamp."); + } + + return value; + } + + private static uint RequireUInt32(JsonElement root, string name) + { + JsonElement element = RequireProperty(root, name); + return element.ValueKind == JsonValueKind.Number && element.TryGetUInt32(out uint value) + ? value + : throw new FormatException( + $"status event field '{name}' is not an unsigned integer."); + } +} diff --git a/src/AcDream.Launcher.Core/Status/StatusFileTailer.cs b/src/AcDream.Launcher.Core/Status/StatusFileTailer.cs new file mode 100644 index 00000000..7450ee58 --- /dev/null +++ b/src/AcDream.Launcher.Core/Status/StatusFileTailer.cs @@ -0,0 +1,155 @@ +using System.Text; + +namespace AcDream.Launcher.Core.Status; + +/// +/// Incremental reader over a host's status.jsonl file (Campaign LA +/// spec §3/§6). Each call to returns the +/// events that arrived since the previous call, tolerating: +/// +/// the file not existing yet (the launcher may start tailing +/// before the host has written its first line — returns no events, not +/// an error); +/// a partial last line (the host may be mid-write when polled — +/// the tailer only advances its read position past the last confirmed +/// '\n'; a still-incomplete tail is re-read, combined with +/// whatever gets appended, on the next poll — never parsed while +/// truncated). +/// +/// One tailer instance owns one file's read position; construct a new +/// one per session. +/// +public interface IStatusEventSource +{ + IReadOnlyList ReadNewEvents(); +} + +/// Creates one independent status source per launched session. +public interface IStatusEventSourceFactory +{ + IStatusEventSource Create(string path); +} + +public sealed class StatusFileTailerFactory : IStatusEventSourceFactory +{ + public IStatusEventSource Create(string path) => new StatusFileTailer(path); +} + +public sealed class StatusFileTailer : IStatusEventSource +{ + private readonly string _path; + private long _position; + + public StatusFileTailer(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + _path = path; + } + + /// + /// Reads and parses every complete line appended to the file since + /// the last call. Returns an empty list (never null, never throws) + /// when the file doesn't exist yet, has been deleted/rotated between + /// the existence check and the open (a TOCTOU window — Campaign LA + /// plan §LA3 review finding F7), or nothing new/complete has arrived + /// since the last poll. + /// + public IReadOnlyList ReadNewEvents() + { + try + { + return ReadNewEventsCore(); + } + catch (Exception ex) when ( + ex is FileNotFoundException or DirectoryNotFoundException or IOException) + { + // The host process deleted/rotated the file (or its + // directory) between File.Exists and the open below, or + // another transient I/O condition hit mid-read — degrade to + // "nothing new this poll" rather than throwing out of a + // method documented never to throw; the next poll picks up + // wherever the file (or its replacement) actually is. + return []; + } + } + + private IReadOnlyList ReadNewEventsCore() + { + if (!File.Exists(_path)) + { + return []; + } + + using var stream = new FileStream( + _path, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete); + + if (stream.Length < _position) + { + // The file was truncated/replaced under us (e.g. a fresh + // session reusing a stale path) — restart from the top + // rather than throwing or silently missing the new content. + _position = 0; + } + + if (stream.Length == _position) + { + return []; + } + + stream.Seek(_position, SeekOrigin.Begin); + int unreadByteCount = checked((int)(stream.Length - _position)); + byte[] buffer = new byte[unreadByteCount]; + int totalRead = 0; + while (totalRead < unreadByteCount) + { + int read = stream.Read(buffer, totalRead, unreadByteCount - totalRead); + if (read == 0) + { + break; + } + + totalRead += read; + } + + var events = new List(); + int lineStart = 0; + + // How far into `buffer` we've confirmed a complete line — this + // is where `_position` advances to. Bytes after this point (an + // in-progress line with no trailing '\n' yet) are simply left + // unread on disk; the next poll re-reads them from `_position` + // combined with whatever the writer appends in between. No + // separate in-memory carry-over buffer is needed. + int consumedThroughIndex = 0; + + for (int i = 0; i < totalRead; i++) + { + if (buffer[i] != (byte)'\n') + { + continue; + } + + int lineEnd = i; + if (lineEnd > lineStart && buffer[lineEnd - 1] == (byte)'\r') + { + lineEnd--; + } + + if (lineEnd > lineStart) + { + string rawLine = Encoding.UTF8.GetString(buffer, lineStart, lineEnd - lineStart); + events.Add(StatusEventParser.Parse(rawLine)); + } + + lineStart = i + 1; + consumedThroughIndex = lineStart; + } + + _position += consumedThroughIndex; + + return events; + } +} diff --git a/src/AcDream.Launcher.Core/Updates/AtomicJsonFile.cs b/src/AcDream.Launcher.Core/Updates/AtomicJsonFile.cs new file mode 100644 index 00000000..919bd737 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/AtomicJsonFile.cs @@ -0,0 +1,84 @@ +using System.Text.Json; + +namespace AcDream.Launcher.Core.Updates; + +internal static class AtomicJsonFile +{ + internal static async Task WriteAsync( + string path, + T value, + JsonSerializerOptions options, + CancellationToken cancellationToken = default) + { + string fullPath = Path.GetFullPath(path); + string directory = Path.GetDirectoryName(fullPath) + ?? throw new InvalidOperationException("The JSON path has no parent directory."); + Directory.CreateDirectory(directory); + string temporaryPath = Path.Combine( + directory, + $".{Path.GetFileName(fullPath)}.{Guid.NewGuid():N}.tmp"); + try + { + await using (var stream = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 16 * 1024, + FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + await JsonSerializer.SerializeAsync( + stream, + value, + options, + cancellationToken) + .ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + stream.Flush(flushToDisk: true); + } + + cancellationToken.ThrowIfCancellationRequested(); + File.Move(temporaryPath, fullPath, overwrite: true); + } + finally + { + VerifiedArtifactDownloader.TryDelete(temporaryPath); + } + } + + internal static async Task WriteBytesAsync( + string path, + ReadOnlyMemory bytes, + CancellationToken cancellationToken = default) + { + string fullPath = Path.GetFullPath(path); + string directory = Path.GetDirectoryName(fullPath) + ?? throw new InvalidOperationException("The file path has no parent directory."); + Directory.CreateDirectory(directory); + string temporaryPath = Path.Combine( + directory, + $".{Path.GetFileName(fullPath)}.{Guid.NewGuid():N}.tmp"); + try + { + await using (var stream = new FileStream( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 16 * 1024, + FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + await stream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + stream.Flush(flushToDisk: true); + } + + cancellationToken.ThrowIfCancellationRequested(); + File.Move(temporaryPath, fullPath, overwrite: true); + } + finally + { + VerifiedArtifactDownloader.TryDelete(temporaryPath); + } + } +} diff --git a/src/AcDream.Launcher.Core/Updates/ClientVersionStore.cs b/src/AcDream.Launcher.Core/Updates/ClientVersionStore.cs new file mode 100644 index 00000000..3b008184 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/ClientVersionStore.cs @@ -0,0 +1,1015 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using AcDream.Launcher.Core.Integrity; +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Updates; + +public sealed record InstalledFileRecord( + string Path, + string Sha256, + long Size, + int UnixMode); + +public sealed record ClientVersionRecord( + int SchemaVersion, + string Version, + string Rid, + string ArchiveSha256, + long ArchiveSize, + IReadOnlyList Files) +{ + public const int CurrentSchemaVersion = 1; +} + +public sealed record ClientActivationPointer( + int SchemaVersion, + string CurrentVersion, + string? PreviousVersion) +{ + public const int CurrentSchemaVersion = 1; +} + +public enum ClientVersionState +{ + Missing, + Verified, + Invalid, +} + +public sealed record ClientVersionResolution( + ClientVersionState State, + string Status, + LauncherVersion? Version, + string? Directory, + string? PreviousVersion, + ClientVersionRecord? Record) +{ + public bool IsVerified => State == ClientVersionState.Verified; +} + +/// +/// Strict installed-version and activation-pointer authority. LA9's DAT/pak +/// record is intentionally not represented here. +/// +public sealed class ClientVersionStore +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = false, + WriteIndented = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + MaxDepth = 32, + }; + + private readonly object _gate = new(); + private readonly Func> _computeSha256; + private ClientVersionResolution _cached = new( + ClientVersionState.Missing, + "No versioned client is installed. Check for updates to install one.", + null, + null, + null, + null); + + public ClientVersionStore( + ApplicationPathSet paths, + Func>? computeSha256 = null) + { + ArgumentNullException.ThrowIfNull(paths); + AppDirectory = Path.Combine(Path.GetFullPath(paths.DataDirectory), "app"); + CurrentPointerPath = Path.Combine(AppDirectory, "current.json"); + PreviousPointerPath = Path.Combine(AppDirectory, "current.previous.json"); + Barrier = new UpdateSessionBarrier(paths.DataDirectory); + _computeSha256 = computeSha256 + ?? ((path, token) => FileIntegrity.ComputeSha256HexAsync(path, token)); + } + + public string AppDirectory { get; } + + public string CurrentPointerPath { get; } + + public string PreviousPointerPath { get; } + + public UpdateSessionBarrier Barrier { get; } + + public ClientVersionResolution CachedResolution + { + get + { + lock (_gate) + { + return _cached; + } + } + } + + public string GetVersionDirectory(LauncherVersion version) => + Path.Combine(AppDirectory, version.Value); + + public static string GetMetadataPath(string versionDirectory) => + Path.Combine(Path.GetFullPath(versionDirectory), "install.json"); + + public async Task LoadAndRecoverAsync( + string rid, + CancellationToken cancellationToken = default) + { + try + { + using UpdateSessionBarrier.ExclusiveLease lease = Barrier.AcquireExclusive(); + return await LoadAndRecoverUnderLeaseAsync(rid, cancellationToken) + .ConfigureAwait(false); + } + catch (LauncherUpdateException ex) when (ex.InnerException is IOException) + { + // Another launcher may legitimately hold a shared session lease. + // Pointer publication is atomic and old versions are retained, so + // a read-only verification remains safe; mutation/recovery waits + // for the next startup without active sessions. + return await LoadCurrentReadOnlyAsync(rid, cancellationToken) + .ConfigureAwait(false); + } + } + + public async Task LoadCurrentReadOnlyAsync( + string rid, + CancellationToken cancellationToken = default) + { + RequireRid(rid); + PointerRead current = await ReadPointerAsync(CurrentPointerPath, cancellationToken) + .ConfigureAwait(false); + ClientVersionResolution resolution = current.Pointer is null + ? (!File.Exists(CurrentPointerPath) + ? new ClientVersionResolution( + ClientVersionState.Missing, + "No versioned client is installed. Check for updates to install one.", + null, + null, + null, + null) + : Invalid(current.Error ?? "The client activation pointer is invalid.")) + : await ResolvePointerAsync(current.Pointer, rid, cancellationToken) + .ConfigureAwait(false); + SetCached(resolution); + return resolution; + } + + internal async Task LoadAndRecoverUnderLeaseAsync( + string rid, + CancellationToken cancellationToken = default) + { + RequireRid(rid); + Directory.CreateDirectory(AppDirectory); + CleanupOwnedResidue(); + + PointerRead current = await ReadPointerAsync(CurrentPointerPath, cancellationToken) + .ConfigureAwait(false); + if (current.Pointer is not null) + { + ClientVersionResolution resolution = await ResolvePointerAsync( + current.Pointer, + rid, + cancellationToken) + .ConfigureAwait(false); + SetCached(resolution); + return resolution; + } + + PointerRead previous = await ReadPointerAsync(PreviousPointerPath, cancellationToken) + .ConfigureAwait(false); + if (previous.Pointer is not null) + { + ClientVersionResolution recovered = await ResolvePointerAsync( + previous.Pointer, + rid, + cancellationToken) + .ConfigureAwait(false); + if (recovered.IsVerified) + { + await WritePointerFileAsync( + CurrentPointerPath, + previous.Pointer, + cancellationToken) + .ConfigureAwait(false); + recovered = recovered with + { + Status = "Recovered the last valid client activation pointer.", + }; + SetCached(recovered); + return recovered; + } + } + + ClientVersionResolution missingOrInvalid = + !File.Exists(CurrentPointerPath) && !File.Exists(PreviousPointerPath) + ? new ClientVersionResolution( + ClientVersionState.Missing, + "No versioned client is installed. Check for updates to install one.", + null, + null, + null, + null) + : new ClientVersionResolution( + ClientVersionState.Invalid, + current.Error + ?? previous.Error + ?? "No valid client activation pointer could be recovered.", + null, + null, + null, + null); + SetCached(missingOrInvalid); + return missingOrInvalid; + } + + internal async Task PromoteAndActivateUnderLeaseAsync( + string stagingDirectory, + LauncherVersion version, + string rid, + ReleaseArtifact artifact, + IReadOnlyList extractedFiles, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(stagingDirectory); + ArgumentNullException.ThrowIfNull(version); + ArgumentNullException.ThrowIfNull(artifact); + ArgumentNullException.ThrowIfNull(extractedFiles); + RequireRid(rid); + + string staging = Path.GetFullPath(stagingDirectory); + RequireOwnedStagingPath(staging); + ValidateRequiredExecutables(extractedFiles, rid, launcherPayload: false); + if (extractedFiles.Any(file => string.Equals( + file.Path, + "install.json", + StringComparison.OrdinalIgnoreCase))) + { + throw new LauncherUpdateException( + "The client ZIP may not provide the launcher's install.json record."); + } + + var record = new ClientVersionRecord( + ClientVersionRecord.CurrentSchemaVersion, + version.Value, + rid, + artifact.Sha256.ToLowerInvariant(), + artifact.Size, + extractedFiles + .Select(file => new InstalledFileRecord( + file.Path, + file.Sha256, + file.Size, + file.UnixMode)) + .OrderBy(file => file.Path, StringComparer.Ordinal) + .ToArray()); + ValidateRecord(record, version, rid); + await AtomicJsonFile.WriteAsync( + GetMetadataPath(staging), + record, + SerializerOptions, + cancellationToken) + .ConfigureAwait(false); + ClientVersionResolution staged = await VerifyVersionDirectoryAsync( + staging, + version, + rid, + cancellationToken) + .ConfigureAwait(false); + if (!staged.IsVerified) + { + throw new LauncherUpdateException(staged.Status); + } + + ClientActivationPointer? oldPointer = (await ReadPointerAsync( + CurrentPointerPath, + cancellationToken) + .ConfigureAwait(false)).Pointer; + string target = GetVersionDirectory(version); + if (Directory.Exists(target)) + { + ClientVersionResolution existing = await VerifyVersionDirectoryAsync( + target, + version, + rid, + cancellationToken) + .ConfigureAwait(false); + if (existing.IsVerified + && existing.Record is not null + && string.Equals( + existing.Record.ArchiveSha256, + artifact.Sha256, + StringComparison.OrdinalIgnoreCase) + && existing.Record.ArchiveSize == artifact.Size) + { + SafeZipExtractor.TryDeleteDirectory(staging); + } + else + { + if (oldPointer is not null + && string.Equals( + oldPointer.CurrentVersion, + version.Value, + StringComparison.Ordinal)) + { + throw new LauncherUpdateException( + "The active client version is corrupt and cannot be replaced in place. " + + "Roll back before repairing it."); + } + + string quarantine = Path.Combine( + AppDirectory, + $".client-corrupt-{Guid.NewGuid():N}"); + Directory.Move(target, quarantine); + try + { + Directory.Move(staging, target); + } + catch + { + Directory.Move(quarantine, target); + throw; + } + + SafeZipExtractor.TryDeleteDirectory(quarantine); + } + } + else + { + Directory.Move(staging, target); + } + + string? previousVersion = oldPointer is null + || string.Equals( + oldPointer.CurrentVersion, + version.Value, + StringComparison.Ordinal) + ? oldPointer?.PreviousVersion + : oldPointer.CurrentVersion; + var pointer = new ClientActivationPointer( + ClientActivationPointer.CurrentSchemaVersion, + version.Value, + previousVersion); + await SavePointerAsync(pointer, cancellationToken).ConfigureAwait(false); + ClientVersionResolution resolution = await ResolvePointerAsync( + pointer, + rid, + cancellationToken) + .ConfigureAwait(false); + if (!resolution.IsVerified) + { + throw new LauncherUpdateException(resolution.Status); + } + + SetCached(resolution); + return resolution; + } + + public async Task RollbackAsync( + string rid, + CancellationToken cancellationToken = default) + { + using UpdateSessionBarrier.ExclusiveLease lease = Barrier.AcquireExclusive(); + PointerRead read = await ReadPointerAsync(CurrentPointerPath, cancellationToken) + .ConfigureAwait(false); + ClientActivationPointer pointer = read.Pointer + ?? throw new LauncherUpdateException( + read.Error ?? "There is no active client version to roll back."); + if (string.IsNullOrEmpty(pointer.PreviousVersion)) + { + throw new LauncherUpdateException( + "There is no previous client version available for rollback."); + } + + LauncherVersion previous = LauncherVersion.Parse(pointer.PreviousVersion); + ClientVersionResolution verified = await VerifyVersionDirectoryAsync( + GetVersionDirectory(previous), + previous, + rid, + cancellationToken) + .ConfigureAwait(false); + if (!verified.IsVerified) + { + throw new LauncherUpdateException( + $"The previous client version cannot be activated: {verified.Status}"); + } + + var swapped = new ClientActivationPointer( + ClientActivationPointer.CurrentSchemaVersion, + previous.Value, + pointer.CurrentVersion); + await SavePointerAsync(swapped, cancellationToken).ConfigureAwait(false); + ClientVersionResolution resolution = await ResolvePointerAsync( + swapped, + rid, + cancellationToken) + .ConfigureAwait(false); + SetCached(resolution); + return resolution; + } + + internal string CreateClientStagingDirectory(Guid transactionId) + { + Directory.CreateDirectory(AppDirectory); + return Path.Combine(AppDirectory, $".client-staging-{transactionId:N}"); + } + + internal static void ValidateRequiredExecutables( + IReadOnlyList files, + string rid, + bool launcherPayload) + { + string suffix = rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty; + string[] required = launcherPayload + ? ["acdream-launcher" + suffix] + : ["AcDream.App" + suffix, "acdream-headless" + suffix]; + foreach (string path in required) + { + ExtractedFileRecord? file = files.SingleOrDefault(candidate => + string.Equals(candidate.Path, path, StringComparison.Ordinal)); + if (file is null) + { + throw new LauncherUpdateException( + $"The release ZIP is missing required root executable '{path}'."); + } + + if (rid.StartsWith("linux-", StringComparison.Ordinal) + && (file.UnixMode & (int)UnixFileMode.UserExecute) == 0) + { + throw new LauncherUpdateException( + $"The Linux release executable '{path}' lacks owner execute permission."); + } + } + } + + private async Task ResolvePointerAsync( + ClientActivationPointer pointer, + string rid, + CancellationToken cancellationToken) + { + string? error = ValidatePointer(pointer); + if (error is not null) + { + return Invalid(error); + } + + LauncherVersion version = LauncherVersion.Parse(pointer.CurrentVersion); + ClientVersionResolution resolution = await VerifyVersionDirectoryAsync( + GetVersionDirectory(version), + version, + rid, + cancellationToken) + .ConfigureAwait(false); + return resolution.IsVerified + ? resolution with { PreviousVersion = pointer.PreviousVersion } + : resolution; + } + + private async Task VerifyVersionDirectoryAsync( + string directory, + LauncherVersion version, + string rid, + CancellationToken cancellationToken) + { + if (!Directory.Exists(directory)) + { + return Invalid($"Client version {version} directory is missing."); + } + + try + { + RejectReparseTree(directory); + ClientVersionRecord? record = await ReadStrictAsync( + GetMetadataPath(directory), + cancellationToken) + .ConfigureAwait(false); + if (record is null) + { + return Invalid($"Client version {version} install.json is missing."); + } + + string? contractError = ValidateRecord(record, version, rid); + if (contractError is not null) + { + return Invalid(contractError); + } + + string[] actualFiles = Directory.EnumerateFiles( + directory, + "*", + SearchOption.AllDirectories) + .Select(path => NormalizeRelative(directory, path)) + .Where(path => !string.Equals( + path, + "install.json", + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal)) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + string[] recordedFiles = record.Files + .Select(file => file.Path) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + if (!actualFiles.SequenceEqual(recordedFiles, StringComparer.Ordinal)) + { + return Invalid( + $"Client version {version} contains missing or unrecorded files."); + } + + foreach (InstalledFileRecord file in record.Files) + { + cancellationToken.ThrowIfCancellationRequested(); + string path = ResolveContained(directory, file.Path); + var info = new FileInfo(path); + if (!info.Exists || info.Length != file.Size) + { + return Invalid( + $"Client version {version} file '{file.Path}' size is corrupt."); + } + + string sha256 = await _computeSha256(path, cancellationToken) + .ConfigureAwait(false); + if (!string.Equals( + sha256, + file.Sha256, + StringComparison.OrdinalIgnoreCase)) + { + return Invalid( + $"Client version {version} file '{file.Path}' SHA-256 is corrupt."); + } + + if (OperatingSystem.IsLinux() + && ((int)File.GetUnixFileMode(path) & 0x1FF) != file.UnixMode) + { + return Invalid( + $"Client version {version} file '{file.Path}' mode is corrupt."); + } + } + + return new ClientVersionResolution( + ClientVersionState.Verified, + $"Client version {version} verified.", + version, + directory, + null, + record); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or JsonException + or NotSupportedException + or FormatException + or LauncherUpdateException) + { + return Invalid( + $"Client version {version} could not be verified: {ex.Message}"); + } + } + + private async Task SavePointerAsync( + ClientActivationPointer pointer, + CancellationToken cancellationToken) + { + string? error = ValidatePointer(pointer); + if (error is not null) + { + throw new LauncherUpdateException(error); + } + + if (File.Exists(CurrentPointerPath)) + { + byte[] previous = await File.ReadAllBytesAsync( + CurrentPointerPath, + cancellationToken) + .ConfigureAwait(false); + PointerRead validPrevious = ParsePointer(previous); + if (validPrevious.Pointer is not null) + { + await AtomicJsonFile.WriteBytesAsync( + PreviousPointerPath, + previous, + cancellationToken) + .ConfigureAwait(false); + } + } + + await WritePointerFileAsync(CurrentPointerPath, pointer, cancellationToken) + .ConfigureAwait(false); + } + + private static Task WritePointerFileAsync( + string path, + ClientActivationPointer pointer, + CancellationToken cancellationToken) => + AtomicJsonFile.WriteAsync(path, pointer, SerializerOptions, cancellationToken); + + private static async Task ReadPointerAsync( + string path, + CancellationToken cancellationToken) + { + if (!File.Exists(path)) + { + return new PointerRead(null, null); + } + + try + { + byte[] bytes = await File.ReadAllBytesAsync(path, cancellationToken) + .ConfigureAwait(false); + return ParsePointer(bytes); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return new PointerRead(null, $"Client pointer could not be read: {ex.Message}"); + } + } + + private static PointerRead ParsePointer(ReadOnlyMemory bytes) + { + try + { + ClientActivationPointer? pointer = ParseStrict(bytes.Span); + string? error = pointer is null + ? "Client pointer is empty." + : ValidatePointer(pointer); + return error is null + ? new PointerRead(pointer, null) + : new PointerRead(null, error); + } + catch (Exception ex) when (ex is JsonException + or LauncherUpdateException + or FormatException) + { + return new PointerRead(null, $"Client pointer is invalid: {ex.Message}"); + } + } + + private static string? ValidatePointer(ClientActivationPointer pointer) + { + if (pointer.SchemaVersion != ClientActivationPointer.CurrentSchemaVersion) + { + return $"Client pointer schema version {pointer.SchemaVersion} is not supported."; + } + + if (!LauncherVersion.TryParse(pointer.CurrentVersion, out _)) + { + return "Client pointer currentVersion is invalid."; + } + + if (pointer.PreviousVersion is not null + && (!LauncherVersion.TryParse(pointer.PreviousVersion, out _) + || string.Equals( + pointer.PreviousVersion, + pointer.CurrentVersion, + StringComparison.Ordinal))) + { + return "Client pointer previousVersion is invalid."; + } + + return null; + } + + private static string? ValidateRecord( + ClientVersionRecord record, + LauncherVersion version, + string rid) + { + if (record.SchemaVersion != ClientVersionRecord.CurrentSchemaVersion) + { + return $"Client install schema version {record.SchemaVersion} is not supported."; + } + + if (!string.Equals(record.Version, version.Value, StringComparison.Ordinal) + || !LauncherVersion.TryParse(record.Version, out _)) + { + return "Client install version does not match its directory."; + } + + if (!string.Equals(record.Rid, rid, StringComparison.Ordinal) + || !LauncherRuntimeIdentity.IsValidRid(record.Rid)) + { + return $"Client install RID does not match '{rid}'."; + } + + if (!ReleaseManifestClient.IsSha256(record.ArchiveSha256) + || record.ArchiveSize <= 0 + || record.ArchiveSize > ReleaseManifestClient.MaximumArtifactBytes) + { + return "Client install archive metadata is invalid."; + } + + if (record.Files is null || record.Files.Count == 0) + { + return "Client install file list is empty."; + } + + var paths = new HashSet(StringComparer.OrdinalIgnoreCase); + string? prior = null; + foreach (InstalledFileRecord file in record.Files) + { + if (!IsNormalizedRelative(file.Path) + || !paths.Add(file.Path) + || !ReleaseManifestClient.IsSha256(file.Sha256) + || file.Size < 0 + || file.UnixMode is < 0 or > 0x1FF + || (prior is not null + && string.Compare(prior, file.Path, StringComparison.Ordinal) >= 0)) + { + return "Client install file metadata is invalid, duplicated, or unsorted."; + } + + prior = file.Path; + } + + string suffix = rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty; + foreach (string required in new[] + { + "AcDream.App" + suffix, + "acdream-headless" + suffix, + }) + { + if (!paths.Contains(required)) + { + return $"Client install is missing '{required}'."; + } + } + + return null; + } + + private static async Task ReadStrictAsync( + string path, + CancellationToken cancellationToken) + { + if (!File.Exists(path)) + { + return default; + } + + byte[] bytes = await File.ReadAllBytesAsync(path, cancellationToken) + .ConfigureAwait(false); + return ParseStrict(bytes); + } + + internal static T? ParseStrict( + ReadOnlySpan bytes, + JsonSerializerOptions? serializerOptions = null) + { + using JsonDocument document = JsonDocument.Parse( + bytes.ToArray(), + new JsonDocumentOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + MaxDepth = 32, + }); + RejectDuplicateProperties(document.RootElement, "$" ); + return document.RootElement.Deserialize( + serializerOptions ?? SerializerOptions); + } + + private static void RejectDuplicateProperties(JsonElement element, string path) + { + if (element.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (JsonProperty property in element.EnumerateObject()) + { + if (!names.Add(property.Name)) + { + throw new LauncherUpdateException( + $"Duplicate JSON property '{path}.{property.Name}' is not allowed."); + } + + RejectDuplicateProperties(property.Value, $"{path}.{property.Name}"); + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + int index = 0; + foreach (JsonElement item in element.EnumerateArray()) + { + RejectDuplicateProperties(item, $"{path}[{index++}]"); + } + } + } + + private void CleanupOwnedResidue() + { + foreach (string path in Directory.EnumerateDirectories( + AppDirectory, + ".client-staging-*", + SearchOption.TopDirectoryOnly)) + { + if (HasCanonicalGuidName( + Path.GetFileName(path), + ".client-staging-", + string.Empty)) + { + SafeZipExtractor.TryDeleteDirectory(path); + } + } + + foreach (string path in Directory.EnumerateDirectories( + AppDirectory, + ".client-corrupt-*", + SearchOption.TopDirectoryOnly)) + { + if (HasCanonicalGuidName( + Path.GetFileName(path), + ".client-corrupt-", + string.Empty)) + { + SafeZipExtractor.TryDeleteDirectory(path); + } + } + + foreach (string path in Directory.EnumerateFiles( + AppDirectory, + ".client-download-*.zip", + SearchOption.TopDirectoryOnly)) + { + if (HasCanonicalGuidName( + Path.GetFileName(path), + ".client-download-", + ".zip")) + { + VerifiedArtifactDownloader.TryDelete(path); + } + } + + foreach (string path in Directory.EnumerateFiles( + AppDirectory, + ".current*.tmp", + SearchOption.TopDirectoryOnly)) + { + string fileName = Path.GetFileName(path); + string[] parts = fileName.Split('.'); + if (parts.Length >= 4 + && string.Equals(parts[^1], "tmp", StringComparison.Ordinal) + && Guid.TryParseExact(parts[^2], "N", out Guid parsed) + && string.Equals( + parsed.ToString("N"), + parts[^2], + StringComparison.Ordinal)) + { + VerifiedArtifactDownloader.TryDelete(path); + } + } + } + + private static bool HasCanonicalGuidName( + string fileName, + string prefix, + string suffix) + { + if (!fileName.StartsWith(prefix, StringComparison.Ordinal) + || !fileName.EndsWith(suffix, StringComparison.Ordinal) + || fileName.Length != prefix.Length + 32 + suffix.Length) + { + return false; + } + + string value = fileName.Substring(prefix.Length, 32); + return Guid.TryParseExact(value, "N", out Guid parsed) + && string.Equals(parsed.ToString("N"), value, StringComparison.Ordinal); + } + + private void RequireOwnedStagingPath(string path) + { + string parent = Path.GetDirectoryName(path) ?? string.Empty; + string fileName = Path.GetFileName(path); + if (!PathsEqual(parent, AppDirectory) + || !HasCanonicalGuidName( + fileName, + ".client-staging-", + string.Empty)) + { + throw new LauncherUpdateException( + "The client extraction path is not an owned LA10 staging directory."); + } + } + + internal static void RejectReparseTree(string root) + { + if ((File.GetAttributes(root) & FileAttributes.ReparsePoint) != 0) + { + throw new LauncherUpdateException("The client version directory is a reparse point."); + } + + var pending = new Stack(); + pending.Push(root); + while (pending.TryPop(out string? directory)) + { + foreach (string path in Directory.EnumerateFileSystemEntries( + directory, + "*", + SearchOption.TopDirectoryOnly)) + { + FileAttributes attributes = File.GetAttributes(path); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new LauncherUpdateException( + $"Client install path '{NormalizeRelative(root, path)}' is a reparse point."); + } + + if ((attributes & FileAttributes.Directory) != 0) + { + pending.Push(path); + } + } + } + } + + private static string NormalizeRelative(string root, string path) => + Path.GetRelativePath(root, path).Replace('\\', '/'); + + internal static bool IsNormalizedRelative(string? path) + { + if (string.IsNullOrEmpty(path) + || path.Length > 512 + || path.IndexOf('\0') >= 0 + || path.Contains('\\', StringComparison.Ordinal) + || path.Contains(':', StringComparison.Ordinal) + || path.StartsWith("/", StringComparison.Ordinal) + || Path.IsPathRooted(path)) + { + return false; + } + + string[] parts = path.Split('/'); + return parts.All(part => + part.Length > 0 + && part is not ("." or "..") + && !part.EndsWith(' ') + && !part.EndsWith('.') + && !part.Any(character => + char.IsControl(character) + || character is '<' or '>' or '"' or '|' or '?' or '*') + && !PortablePathRules.IsWindowsDeviceName(part)); + } + + internal static string ResolveContained(string root, string relative) + { + if (!IsNormalizedRelative(relative)) + { + throw new LauncherUpdateException($"Unsafe relative path '{relative}'."); + } + + string fullRoot = Path.GetFullPath(root); + string path = Path.GetFullPath( + Path.Combine(fullRoot, relative.Replace('/', Path.DirectorySeparatorChar))); + string prefix = Path.EndsInDirectorySeparator(fullRoot) + ? fullRoot + : fullRoot + Path.DirectorySeparatorChar; + if (!path.StartsWith( + prefix, + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal)) + { + throw new LauncherUpdateException($"Path '{relative}' escaped its root."); + } + + return path; + } + + private static bool PathsEqual(string left, string right) => + string.Equals( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)), + Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)), + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal); + + private static void RequireRid(string rid) + { + if (!LauncherRuntimeIdentity.IsValidRid(rid)) + { + throw new ArgumentException("RID is invalid.", nameof(rid)); + } + } + + private void SetCached(ClientVersionResolution resolution) + { + lock (_gate) + { + _cached = resolution; + } + } + + private static ClientVersionResolution Invalid(string status) => + new(ClientVersionState.Invalid, status, null, null, null, null); + + private sealed record PointerRead(ClientActivationPointer? Pointer, string? Error); +} diff --git a/src/AcDream.Launcher.Core/Updates/LauncherRuntimeIdentity.cs b/src/AcDream.Launcher.Core/Updates/LauncherRuntimeIdentity.cs new file mode 100644 index 00000000..7bf37410 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/LauncherRuntimeIdentity.cs @@ -0,0 +1,33 @@ +using System.Runtime.InteropServices; + +namespace AcDream.Launcher.Core.Updates; + +public static class LauncherRuntimeIdentity +{ + public static string DetectRid() + { + string os = OperatingSystem.IsWindows() + ? "win" + : OperatingSystem.IsLinux() + ? "linux" + : throw new PlatformNotSupportedException( + "The launcher updater supports Windows and Linux only."); + string architecture = RuntimeInformation.ProcessArchitecture switch + { + Architecture.X64 => "x64", + Architecture.Arm64 => "arm64", + _ => throw new PlatformNotSupportedException( + $"The launcher updater does not support {RuntimeInformation.ProcessArchitecture}."), + }; + return $"{os}-{architecture}"; + } + + internal static bool IsValidRid(string? rid) => + !string.IsNullOrEmpty(rid) + && rid.Length <= 64 + && rid[0] is >= 'a' and <= 'z' + && rid.All(character => + character is >= 'a' and <= 'z' + or >= '0' and <= '9' + or '-'); +} diff --git a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs new file mode 100644 index 00000000..6decb4b9 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs @@ -0,0 +1,477 @@ +using System.Diagnostics; + +namespace AcDream.Launcher.Core.Updates; + +public sealed record SelfUpdateStartupResult( + bool ShouldExit, + int ExitCode, + string[] RemainingArguments); + +/// +/// Process-level self-update bootstrap. Every child argument is passed through +/// with shell execution disabled. +/// +public static class LauncherSelfUpdateBootstrap +{ + public const string HelperArgument = "--acdream-self-update-helper-v1"; + public const string ConfirmArgument = "--acdream-self-update-confirm-v1"; + internal const int UpdateLeaseBusyExitCode = 73; + private const string InternalArgumentPrefix = "--acdream-self-update-"; + private static readonly TimeSpan ConfirmationTimeout = TimeSpan.FromSeconds(30); + + public static async Task HandleAsync( + string[] args, + LauncherSelfUpdateManager manager, + string launcherBaseDirectory, + string currentExecutablePath, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(args); + ArgumentNullException.ThrowIfNull(manager); + string baseDirectory = Path.TrimEndingDirectorySeparator( + Path.GetFullPath(launcherBaseDirectory)); + string executable = Path.GetFullPath(currentExecutablePath); + + if (args.Length > 0 + && string.Equals(args[0], HelperArgument, StringComparison.Ordinal)) + { + if (args.Length < 4 + || !int.TryParse( + args[1], + System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, + out int parentPid) + || parentPid <= 0) + { + return new SelfUpdateStartupResult(true, 64, []); + } + + int exitCode = await RunHelperAsync( + manager, + baseDirectory, + executable, + parentPid, + args[2], + args[3], + args[4..], + cancellationToken) + .ConfigureAwait(false); + return new SelfUpdateStartupResult(true, exitCode, []); + } + + if (args.Length > 0 + && string.Equals(args[0], ConfirmArgument, StringComparison.Ordinal)) + { + if (args.Length < 2) + { + return new SelfUpdateStartupResult(true, 64, []); + } + + if (manager.Barrier.TryAcquireSession( + out UpdateSessionBarrier.SessionLease? unexpectedSharedLease)) + { + unexpectedSharedLease?.Dispose(); + throw new LauncherUpdateException( + "Self-update confirmation is trusted only while its helper owns " + + "the exclusive update lease."); + } + + await manager.ConfirmAsync( + args[1], + baseDirectory, + executable, + cancellationToken) + .ConfigureAwait(false); + // The helper that owns the exclusive lease observes this durable + // receipt and performs authoritative completion. A later ordinary + // startup also completes it if that helper crashes after receipt. + return new SelfUpdateStartupResult(false, 0, args[2..]); + } + + if (args.Length > 0 + && args[0].StartsWith(InternalArgumentPrefix, StringComparison.Ordinal)) + { + // Internal modes are an exact vocabulary. In particular, an old + // deferred-restart marker must never become an authorization to + // skip a pending recovery state. + return new SelfUpdateStartupResult(true, 64, []); + } + + // Load first: an invalid/ambiguous journal must fail closed even when + // another process currently owns the update barrier. + _ = await manager.LoadPendingAsync(cancellationToken).ConfigureAwait(false); + + if (!manager.Barrier.TryAcquireExclusive( + out UpdateSessionBarrier.ExclusiveLease? startupLease)) + { + if (!manager.Barrier.TryAcquireSession( + out UpdateSessionBarrier.SessionLease? sharedLease)) + { + throw new LauncherUpdateException( + "Launcher startup is blocked by an active update or recovery transaction."); + } + + using (sharedLease + ?? throw new InvalidOperationException("Shared startup lease is missing.")) + { + SelfUpdatePlan? blockedPlan = await manager.LoadPendingAsync(cancellationToken) + .ConfigureAwait(false); + if (blockedPlan is null) + { + return new SelfUpdateStartupResult(false, 0, args); + } + + ValidateCanonicalStartup(blockedPlan, baseDirectory, executable); + if (blockedPlan.State != SelfUpdatePlanState.Staged) + { + throw new LauncherUpdateException( + $"Self-update state '{blockedPlan.State}' requires exclusive recovery."); + } + + // A verified staged update may wait while an already-running + // session holds the shared lease. No helper is spawned, so a + // late session lease cannot create a restart loop. + return new SelfUpdateStartupResult(false, 0, args); + } + } + + using (UpdateSessionBarrier.ExclusiveLease lease = startupLease + ?? throw new InvalidOperationException("Exclusive startup lease is missing.")) + { + SelfUpdatePlan? plan = await manager.LoadPendingAsync(cancellationToken) + .ConfigureAwait(false); + _ = manager.CleanupOwnedResidueUnderLease( + plan, + baseDirectory, + lease); + if (plan is null) + { + return new SelfUpdateStartupResult(false, 0, args); + } + + ValidateCanonicalStartup(plan, baseDirectory, executable); + + if (plan.State == SelfUpdatePlanState.AwaitingConfirmation) + { + if (!manager.IsConfirmed(plan.TransactionId)) + { + await manager.ConfirmAsync( + plan.TransactionId, + baseDirectory, + executable, + cancellationToken) + .ConfigureAwait(false); + } + + await manager.CompleteConfirmedAsync( + plan.TransactionId, + baseDirectory, + cancellationToken) + .ConfigureAwait(false); + _ = manager.CleanupOwnedResidueUnderLease( + pending: null, + baseDirectory, + lease); + return new SelfUpdateStartupResult(false, 0, args); + } + + if (plan.State is SelfUpdatePlanState.Applying + or SelfUpdatePlanState.RolledBack) + { + if (plan.State == SelfUpdatePlanState.Applying) + { + plan = await manager.RecoverApplyingAsync( + baseDirectory, + cancellationToken) + .ConfigureAwait(false); + } + + if (plan.State != SelfUpdatePlanState.RolledBack) + { + throw new LauncherUpdateException( + "The interrupted self-update did not produce a rollback receipt."); + } + + await manager.CompleteRolledBackAsync( + plan.TransactionId, + baseDirectory, + lease, + cancellationToken) + .ConfigureAwait(false); + _ = manager.CleanupOwnedResidueUnderLease( + pending: null, + baseDirectory, + lease); + return new SelfUpdateStartupResult(false, 0, args); + } + + if (plan.State != SelfUpdatePlanState.Staged) + { + throw new LauncherUpdateException( + $"Self-update state '{plan.State}' cannot start a helper."); + } + + string helperPath = manager.GetStagedLauncherPath(plan); + var startInfo = new ProcessStartInfo(helperPath) + { + UseShellExecute = false, + WorkingDirectory = manager.GetPayloadDirectory(plan.TransactionId), + }; + startInfo.ArgumentList.Add(HelperArgument); + startInfo.ArgumentList.Add( + Environment.ProcessId.ToString( + System.Globalization.CultureInfo.InvariantCulture)); + startInfo.ArgumentList.Add(baseDirectory); + startInfo.ArgumentList.Add(plan.TransactionId); + foreach (string argument in args) + { + startInfo.ArgumentList.Add(argument); + } + + _ = Process.Start(startInfo) + ?? throw new LauncherUpdateException( + "The launcher self-update helper could not be started."); + return new SelfUpdateStartupResult(true, 0, []); + } + } + + private static async Task RunHelperAsync( + LauncherSelfUpdateManager manager, + string helperBaseDirectory, + string currentExecutablePath, + int parentPid, + string targetDirectory, + string transactionId, + IReadOnlyList publicArguments, + CancellationToken cancellationToken) + { + SelfUpdatePlan plan = await manager.LoadPendingAsync(cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException("The helper found no pending self-update."); + if (plan.State != SelfUpdatePlanState.Staged + || !string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal)) + { + throw new LauncherUpdateException( + "The helper mode does not match a staged self-update transaction."); + } + + if (!PathsEqual(plan.TargetDirectory, targetDirectory)) + { + throw new LauncherUpdateException( + "The helper target does not match the pending self-update."); + } + + string expectedHelperDirectory = manager.GetPayloadDirectory(plan.TransactionId); + string expectedHelperPath = manager.GetStagedLauncherPath(plan); + if (!PathsEqual(helperBaseDirectory, expectedHelperDirectory) + || !PathsEqual(currentExecutablePath, expectedHelperPath)) + { + throw new LauncherUpdateException( + "Self-update helper mode is trusted only from the staged launcher payload."); + } + + string launcherPath = ClientVersionStore.ResolveContained( + targetDirectory, + GetLauncherFileName(plan.Rid)); + var startInfo = new ProcessStartInfo(launcherPath) + { + UseShellExecute = false, + WorkingDirectory = Path.GetFullPath(targetDirectory), + }; + startInfo.ArgumentList.Add(ConfirmArgument); + startInfo.ArgumentList.Add(transactionId); + foreach (string argument in publicArguments) + { + startInfo.ArgumentList.Add(argument); + } + + await WaitForParentExitAsync(parentPid, cancellationToken).ConfigureAwait(false); + if (!manager.Barrier.TryAcquireExclusive( + out UpdateSessionBarrier.ExclusiveLease? updateLease)) + { + // Do not restart the canonical launcher: it would immediately see + // the same staged plan and create an unbounded helper loop. + return UpdateLeaseBusyExitCode; + } + + ProcessStartInfo? restoredStart = null; + using (UpdateSessionBarrier.ExclusiveLease lease = updateLease + ?? throw new InvalidOperationException("Exclusive update lease is missing.")) + { + plan = await manager.LoadPendingAsync(cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException( + "The helper found no pending self-update after acquiring the lease."); + if (plan.State != SelfUpdatePlanState.Staged + || !string.Equals( + plan.TransactionId, + transactionId, + StringComparison.Ordinal) + || !PathsEqual(plan.TargetDirectory, targetDirectory)) + { + throw new LauncherUpdateException( + "The pending self-update changed before the helper acquired its lease."); + } + + _ = manager.CleanupOwnedResidueUnderLease( + plan, + targetDirectory, + lease); + Process? replacement = null; + try + { + plan = await manager.ApplyPendingAsync(targetDirectory, cancellationToken) + .ConfigureAwait(false); + replacement = Process.Start(startInfo) + ?? throw new LauncherUpdateException( + "The updated launcher could not be started."); + DateTimeOffset deadline = DateTimeOffset.UtcNow + ConfirmationTimeout; + while (!manager.IsConfirmed(transactionId)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (replacement.HasExited || DateTimeOffset.UtcNow >= deadline) + { + throw new LauncherUpdateException( + replacement.HasExited + ? $"The updated launcher exited with code {replacement.ExitCode} " + + "before confirming startup." + : "The updated launcher did not confirm startup in time."); + } + + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + + await manager.CompleteConfirmedAsync( + transactionId, + targetDirectory, + cancellationToken) + .ConfigureAwait(false); + return 0; + } + catch + { + if (replacement is { HasExited: false }) + { + replacement.Kill(entireProcessTree: true); + await replacement.WaitForExitAsync(CancellationToken.None) + .ConfigureAwait(false); + } + + try + { + SelfUpdatePlan? pending = await manager.LoadPendingAsync( + CancellationToken.None) + .ConfigureAwait(false); + SelfUpdatePlan? rollbackReceipt = pending?.State switch + { + SelfUpdatePlanState.Applying => + await manager.RecoverApplyingAsync( + targetDirectory, + CancellationToken.None) + .ConfigureAwait(false), + SelfUpdatePlanState.AwaitingConfirmation => + await manager.RollbackAwaitingConfirmationAsync( + targetDirectory, + CancellationToken.None) + .ConfigureAwait(false), + SelfUpdatePlanState.RolledBack => pending, + _ => null, + }; + if (rollbackReceipt?.State != SelfUpdatePlanState.RolledBack) + { + return 75; + } + + await manager.VerifyRestoredPriorAsync( + targetDirectory, + CancellationToken.None) + .ConfigureAwait(false); + } + catch + { + // An ambiguous state must not start either executable. + return 75; + } + + restoredStart = new ProcessStartInfo(launcherPath) + { + UseShellExecute = false, + WorkingDirectory = Path.GetFullPath(targetDirectory), + }; + foreach (string argument in publicArguments) + { + restoredStart.ArgumentList.Add(argument); + } + } + finally + { + replacement?.Dispose(); + } + } + + // Release the helper's exclusive barrier before restarting the + // restored canonical launcher. It will observe the durable RolledBack + // receipt through the ordinary startup path, re-verify it, finalize + // recovery, and continue with no privileged bypass argument. + if (restoredStart is null || Process.Start(restoredStart) is null) + { + return 75; + } + + return 74; + } + + private static string GetLauncherFileName(string rid) => + "acdream-launcher" + + (rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty); + + private static void ValidateCanonicalStartup( + SelfUpdatePlan plan, + string baseDirectory, + string executable) + { + if (!PathsEqual(plan.TargetDirectory, baseDirectory)) + { + throw new LauncherUpdateException( + "The pending self-update targets a different launcher directory."); + } + + string expectedExecutable = ClientVersionStore.ResolveContained( + baseDirectory, + GetLauncherFileName(plan.Rid)); + if (!PathsEqual(executable, expectedExecutable)) + { + throw new LauncherUpdateException( + "Self-update can run only from the published acdream-launcher executable."); + } + } + + private static async Task WaitForParentExitAsync( + int parentPid, + CancellationToken cancellationToken) + { + try + { + using Process parent = Process.GetProcessById(parentPid); + if (parent.Id == Environment.ProcessId) + { + throw new LauncherUpdateException( + "The self-update helper cannot wait on itself."); + } + + await parent.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + } + catch (ArgumentException) + { + // The parent exited before the helper opened it. + } + } + + private static bool PathsEqual(string left, string right) => + string.Equals( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)), + Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)), + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal); +} diff --git a/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs new file mode 100644 index 00000000..e5f1d467 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs @@ -0,0 +1,1836 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Updates; + +public enum SelfUpdatePlanState +{ + Staged, + Applying, + AwaitingConfirmation, + RolledBack, +} + +public enum SelfUpdateApplyOperation +{ + Install, + Remove, +} + +public sealed record SelfUpdateApplyEntry( + string Path, + SelfUpdateApplyOperation Operation, + bool HadOriginal, + string? PriorSha256, + long? PriorSize, + int? PriorUnixMode, + string? ReplacementSha256, + long? ReplacementSize, + int? ReplacementUnixMode); + +public sealed record SelfUpdatePlan( + int SchemaVersion, + string TransactionId, + SelfUpdatePlanState State, + string Version, + string Rid, + string TargetDirectory, + string ArchiveSha256, + long ArchiveSize, + IReadOnlyList Files, + IReadOnlyList? Apply) +{ + public const int CurrentSchemaVersion = 3; +} + +public sealed record LauncherBinaryInstallRecord( + int SchemaVersion, + string Version, + string Rid, + IReadOnlyList Files) +{ + public const int CurrentSchemaVersion = 1; +} + +public sealed record SelfUpdateStageResult( + LauncherVersion Version, + string PendingPlanPath, + string Status); + +internal enum SelfUpdateApplyBoundary +{ + AfterTargetMutation, +} + +internal sealed record SelfUpdateApplyObservation( + SelfUpdateApplyBoundary Boundary, + string Path, + SelfUpdateApplyOperation Operation); + +/// +/// Durable self-update transaction owner. Verified payload bytes are copied +/// into a target-local transaction before mutation. Existing targets use a +/// same-filesystem atomic replace with a target-local backup, so the canonical +/// executable is never absent at a durable boundary. +/// +public sealed class LauncherSelfUpdateManager +{ + public const string InstallRecordFileName = "launcher.install.json"; + private const string TargetTransactionPrefix = ".acdream-self-update-"; + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = false, + WriteIndented = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + MaxDepth = 32, + Converters = + { + new JsonStringEnumConverter( + JsonNamingPolicy.CamelCase, + allowIntegerValues: false), + new JsonStringEnumConverter( + JsonNamingPolicy.CamelCase, + allowIntegerValues: false), + }, + }; + + private readonly VerifiedArtifactDownloader _downloader; + private readonly SafeZipExtractor _extractor; + private readonly Action? _applyObserver; + + private sealed record JournalFileMetadata(string Sha256, long Size, int UnixMode); + + private sealed record RollbackAction( + SelfUpdateApplyEntry Entry, + string TargetPath, + string BackupPath, + string DiscardPath); + + public LauncherSelfUpdateManager( + ApplicationPathSet paths, + HttpClient httpClient, + SafeZipExtractor? extractor = null) + : this(paths, httpClient, extractor, applyObserver: null) + { + } + + internal LauncherSelfUpdateManager( + ApplicationPathSet paths, + HttpClient httpClient, + SafeZipExtractor? extractor, + Action? applyObserver) + { + ArgumentNullException.ThrowIfNull(paths); + RootDirectory = Path.Combine( + Path.GetFullPath(paths.DataDirectory), + "launcher-update"); + TransactionsDirectory = Path.Combine(RootDirectory, "transactions"); + PendingPlanPath = Path.Combine(RootDirectory, "pending.json"); + Barrier = new UpdateSessionBarrier(paths.DataDirectory); + _downloader = new VerifiedArtifactDownloader( + httpClient ?? throw new ArgumentNullException(nameof(httpClient))); + _extractor = extractor ?? new SafeZipExtractor(); + _applyObserver = applyObserver; + } + + public string RootDirectory { get; } + + public string TransactionsDirectory { get; } + + public string PendingPlanPath { get; } + + public UpdateSessionBarrier Barrier { get; } + + public Task StageAsync( + ReleaseManifest manifest, + string rid, + string targetDirectory, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(manifest); + return StageAsync( + manifest.Version, + rid, + manifest.RequireLauncher(rid), + targetDirectory, + progress, + cancellationToken); + } + + internal async Task StageAsync( + LauncherVersion version, + string rid, + ReleaseArtifact artifact, + string targetDirectory, + IProgress? progress, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(version); + ArgumentNullException.ThrowIfNull(artifact); + if (!LauncherRuntimeIdentity.IsValidRid(rid)) + { + throw new ArgumentException("RID is invalid.", nameof(rid)); + } + + using UpdateSessionBarrier.ExclusiveLease lease = Barrier.AcquireExclusive(); + string target = NormalizeTargetDirectory(targetDirectory); + Directory.CreateDirectory(RootDirectory); + Directory.CreateDirectory(TransactionsDirectory); + SelfUpdatePlan? existing = await LoadPendingAsync(cancellationToken) + .ConfigureAwait(false); + _ = CleanupOwnedResidueUnderLease(existing, target, lease); + if (existing is not null) + { + throw new LauncherUpdateException( + $"Launcher self-update {existing.Version} is already {existing.State}. " + + "Restart the launcher to finish it before staging another."); + } + + string transactionId = Guid.NewGuid().ToString("N"); + string transactionDirectory = GetTransactionDirectory(transactionId); + string payloadDirectory = GetPayloadDirectory(transactionId); + string archivePath = Path.Combine(transactionDirectory, "launcher.zip"); + Directory.CreateDirectory(transactionDirectory); + try + { + _ = await _downloader.DownloadAsync( + artifact, + archivePath, + progress, + cancellationToken) + .ConfigureAwait(false); + IReadOnlyList extracted = await _extractor.ExtractAsync( + archivePath, + payloadDirectory, + cancellationToken) + .ConfigureAwait(false); + ClientVersionStore.ValidateRequiredExecutables( + extracted, + rid, + launcherPayload: true); + if (extracted.Any(file => string.Equals( + file.Path, + InstallRecordFileName, + StringComparison.OrdinalIgnoreCase))) + { + throw new LauncherUpdateException( + $"The launcher ZIP may not provide '{InstallRecordFileName}'."); + } + + var plan = new SelfUpdatePlan( + SelfUpdatePlan.CurrentSchemaVersion, + transactionId, + SelfUpdatePlanState.Staged, + version.Value, + rid, + target, + artifact.Sha256.ToLowerInvariant(), + artifact.Size, + extracted.Select(file => new InstalledFileRecord( + file.Path, + file.Sha256, + file.Size, + file.UnixMode)) + .OrderBy(file => file.Path, StringComparer.Ordinal) + .ToArray(), + null); + ValidatePlan(plan, target); + await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false); + VerifiedArtifactDownloader.TryDelete(archivePath); + return new SelfUpdateStageResult( + version, + PendingPlanPath, + $"Launcher {version} is staged and will be applied on next start."); + } + catch + { + if (!File.Exists(PendingPlanPath)) + { + SafeZipExtractor.TryDeleteDirectory(transactionDirectory); + } + + throw; + } + } + + /// + /// Reads the durable plan without mutating any transaction-owned path. + /// Cleanup is a separate operation that requires the exclusive OS lease. + /// + public async Task LoadPendingAsync( + CancellationToken cancellationToken = default) + { + if (!File.Exists(PendingPlanPath)) + { + return null; + } + + try + { + byte[] bytes = await File.ReadAllBytesAsync(PendingPlanPath, cancellationToken) + .ConfigureAwait(false); + SelfUpdatePlan? plan = ClientVersionStore.ParseStrict( + bytes, + SerializerOptions); + if (plan is null) + { + throw new LauncherUpdateException("The self-update plan is empty."); + } + + ValidatePlan(plan, plan.TargetDirectory); + return plan; + } + catch (OperationCanceledException) + { + throw; + } + catch (LauncherUpdateException) + { + throw; + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or JsonException + or FormatException + or NotSupportedException) + { + throw new LauncherUpdateException( + $"The pending launcher self-update is invalid: {ex.Message}", + ex); + } + } + + public async Task ApplyPendingAsync( + string expectedTargetDirectory, + CancellationToken cancellationToken = default) + { + string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory); + SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException("There is no staged launcher self-update."); + ValidatePlan(plan, expectedTarget); + + if (plan.State == SelfUpdatePlanState.AwaitingConfirmation) + { + return plan; + } + + if (plan.State == SelfUpdatePlanState.Applying) + { + plan = await RollbackApplyingAsync(plan, cancellationToken) + .ConfigureAwait(false); + } + + if (plan.State == SelfUpdatePlanState.RolledBack) + { + await VerifyRestoredPriorAsync(plan, expectedTarget, cancellationToken) + .ConfigureAwait(false); + plan = plan with + { + State = SelfUpdatePlanState.Staged, + Apply = null, + }; + await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false); + } + + await VerifyPayloadAsync(plan, cancellationToken).ConfigureAwait(false); + LauncherBinaryInstallRecord? previous = await ReadAndVerifyInstallRecordAsync( + expectedTarget, + plan.Rid, + cancellationToken) + .ConfigureAwait(false); + IReadOnlyList apply = await BuildApplyJournalAsync( + plan, + previous, + expectedTarget, + cancellationToken) + .ConfigureAwait(false); + apply = await PrepareTargetTransactionAsync(plan, apply, cancellationToken) + .ConfigureAwait(false); + plan = plan with + { + State = SelfUpdatePlanState.Applying, + Apply = apply, + }; + await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false); + + try + { + foreach (SelfUpdateApplyEntry entry in plan.Apply) + { + cancellationToken.ThrowIfCancellationRequested(); + await ApplyEntryAsync(plan, entry, cancellationToken) + .ConfigureAwait(false); + _applyObserver?.Invoke(new SelfUpdateApplyObservation( + SelfUpdateApplyBoundary.AfterTargetMutation, + entry.Path, + entry.Operation)); + } + + plan = plan with { State = SelfUpdatePlanState.AwaitingConfirmation }; + await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false); + return plan; + } + catch + { + await RollbackApplyingAsync(plan, CancellationToken.None) + .ConfigureAwait(false); + throw; + } + } + + public async Task RecoverApplyingAsync( + string expectedTargetDirectory, + CancellationToken cancellationToken = default) + { + string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory); + SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException("There is no pending self-update."); + ValidatePlan(plan, expectedTarget); + return plan.State == SelfUpdatePlanState.Applying + ? await RollbackApplyingAsync(plan, cancellationToken).ConfigureAwait(false) + : plan; + } + + internal async Task VerifyRestoredPriorAsync( + string expectedTargetDirectory, + CancellationToken cancellationToken = default) + { + string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory); + SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException("There is no rolled-back self-update."); + ValidatePlan(plan, expectedTarget); + if (plan.State != SelfUpdatePlanState.RolledBack) + { + throw new LauncherUpdateException( + "The pending self-update has no verified rollback receipt."); + } + + await VerifyRestoredPriorAsync(plan, expectedTarget, cancellationToken) + .ConfigureAwait(false); + } + + public async Task ConfirmAsync( + string transactionId, + string expectedTargetDirectory, + string currentExecutablePath, + CancellationToken cancellationToken = default) + { + SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException("There is no self-update to confirm."); + string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory); + ValidatePlan(plan, expectedTarget); + if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal) + || plan.State != SelfUpdatePlanState.AwaitingConfirmation) + { + throw new LauncherUpdateException( + "The running launcher does not match the pending confirmation plan."); + } + + string expectedExecutable = ClientVersionStore.ResolveContained( + expectedTarget, + GetLauncherFileName(plan.Rid)); + if (!PathsEqual(expectedExecutable, currentExecutablePath)) + { + throw new LauncherUpdateException( + "Only the newly installed launcher executable may confirm self-update."); + } + + await VerifyAppliedTargetsAsync(plan, expectedTarget, cancellationToken) + .ConfigureAwait(false); + await VerifyInstalledOwnershipMatchesPlanAsync( + plan, + expectedTarget, + cancellationToken) + .ConfigureAwait(false); + string confirmationPath = GetConfirmationPath(transactionId); + await AtomicJsonFile.WriteBytesAsync( + confirmationPath, + "confirmed"u8.ToArray(), + cancellationToken) + .ConfigureAwait(false); + } + + public bool IsConfirmed(string transactionId) => + File.Exists(GetConfirmationPath(transactionId)); + + public async Task CompleteConfirmedAsync( + string transactionId, + string expectedTargetDirectory, + CancellationToken cancellationToken = default) + { + SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException("There is no self-update to complete."); + string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory); + ValidatePlan(plan, expectedTarget); + if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal) + || plan.State != SelfUpdatePlanState.AwaitingConfirmation + || !IsConfirmed(transactionId)) + { + throw new LauncherUpdateException("The self-update is not confirmed."); + } + + File.Delete(PendingPlanPath); + SafeZipExtractor.TryDeleteDirectory(GetTargetTransactionDirectory(plan)); + SafeZipExtractor.TryDeleteDirectory(GetTransactionDirectory(transactionId)); + } + + /// + /// Finalizes a durable rollback only after the prior owned launcher set + /// has been freshly re-verified while the caller holds the update + /// barrier. A failed self-update is abandoned rather than silently + /// re-staged, so an ordinary restart cannot enter an automatic retry + /// loop. + /// + internal async Task CompleteRolledBackAsync( + string transactionId, + string expectedTargetDirectory, + UpdateSessionBarrier.ExclusiveLease lease, + CancellationToken cancellationToken = default) + { + Barrier.RequireOwned(lease); + string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory); + SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException("There is no rolled-back self-update."); + ValidatePlan(plan, expectedTarget); + if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal) + || plan.State != SelfUpdatePlanState.RolledBack) + { + throw new LauncherUpdateException( + "The self-update does not have the expected rollback receipt."); + } + + await VerifyRestoredPriorAsync(plan, expectedTarget, cancellationToken) + .ConfigureAwait(false); + File.Delete(PendingPlanPath); + SafeZipExtractor.TryDeleteDirectory(GetTargetTransactionDirectory(plan)); + SafeZipExtractor.TryDeleteDirectory(GetTransactionDirectory(transactionId)); + } + + public async Task RollbackAwaitingConfirmationAsync( + string expectedTargetDirectory, + CancellationToken cancellationToken = default) + { + string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory); + SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException("There is no self-update to roll back."); + ValidatePlan(plan, expectedTarget); + if (plan.State != SelfUpdatePlanState.AwaitingConfirmation) + { + throw new LauncherUpdateException( + "The pending self-update is not awaiting confirmation."); + } + + plan = plan with { State = SelfUpdatePlanState.Applying }; + await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false); + return await RollbackApplyingAsync(plan, cancellationToken) + .ConfigureAwait(false); + } + + public string GetTransactionDirectory(string transactionId) + { + RequireTransactionId(transactionId); + return Path.Combine(TransactionsDirectory, transactionId); + } + + public string GetPayloadDirectory(string transactionId) => + Path.Combine(GetTransactionDirectory(transactionId), "payload"); + + public string GetConfirmationPath(string transactionId) => + Path.Combine(GetTransactionDirectory(transactionId), "confirmed"); + + internal string GetTargetTransactionDirectory(SelfUpdatePlan plan) => + Path.Combine(plan.TargetDirectory, TargetTransactionPrefix + plan.TransactionId); + + internal string GetStagedLauncherPath(SelfUpdatePlan plan) => + ClientVersionStore.ResolveContained( + GetPayloadDirectory(plan.TransactionId), + GetLauncherFileName(plan.Rid)); + + internal bool CleanupOwnedResidueUnderLease( + SelfUpdatePlan? pending, + string targetDirectory, + UpdateSessionBarrier.ExclusiveLease lease) + { + Barrier.RequireOwned(lease); + string target = NormalizeTargetDirectory(targetDirectory); + CleanupDataResidue(pending?.TransactionId); + string? keepTarget = pending is + { + State: SelfUpdatePlanState.Applying or SelfUpdatePlanState.AwaitingConfirmation, + } + ? pending.TransactionId + : null; + CleanupTargetResidue(target, keepTarget); + return !HasReclaimableResidue(pending?.TransactionId, target, keepTarget); + } + + private static string GetLauncherFileName(string rid) => + "acdream-launcher" + + (rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty); + + private async Task> BuildApplyJournalAsync( + SelfUpdatePlan plan, + LauncherBinaryInstallRecord? previous, + string targetDirectory, + CancellationToken cancellationToken) + { + var operations = new Dictionary( + StringComparer.OrdinalIgnoreCase); + foreach (InstalledFileRecord file in plan.Files) + { + operations.Add(file.Path, SelfUpdateApplyOperation.Install); + } + + operations.Add(InstallRecordFileName, SelfUpdateApplyOperation.Install); + if (previous is not null) + { + foreach (InstalledFileRecord file in previous.Files) + { + if (!operations.ContainsKey(file.Path)) + { + operations.Add(file.Path, SelfUpdateApplyOperation.Remove); + } + } + } + + var result = new List(operations.Count); + foreach ((string path, SelfUpdateApplyOperation operation) in operations + .OrderBy(item => item.Key, StringComparer.Ordinal)) + { + string targetPath = ClientVersionStore.ResolveContained(targetDirectory, path); + EnsureSafeParent(targetDirectory, targetPath); + JournalFileMetadata? prior = await CaptureOptionalFileMetadataAsync( + targetPath, + $"Self-update target '{path}'", + cancellationToken) + .ConfigureAwait(false); + bool hadOriginal = prior is not null; + if (operation == SelfUpdateApplyOperation.Remove && !hadOriginal) + { + throw new LauncherUpdateException( + $"Owned obsolete launcher file '{path}' is missing."); + } + + if (string.Equals( + path, + GetLauncherFileName(plan.Rid), + StringComparison.OrdinalIgnoreCase) + && !hadOriginal) + { + throw new LauncherUpdateException( + "The canonical launcher executable is missing before self-update."); + } + + result.Add(new SelfUpdateApplyEntry( + path, + operation, + hadOriginal, + prior?.Sha256, + prior?.Size, + prior?.UnixMode, + ReplacementSha256: null, + ReplacementSize: null, + ReplacementUnixMode: null)); + } + + return result; + } + + private async Task> PrepareTargetTransactionAsync( + SelfUpdatePlan plan, + IReadOnlyList apply, + CancellationToken cancellationToken) + { + string swap = GetTargetTransactionDirectory(plan); + if (Directory.Exists(swap)) + { + ClientVersionStore.RejectReparseTree(swap); + SafeZipExtractor.TryDeleteDirectory(swap); + } + + if (Directory.Exists(swap) || File.Exists(swap)) + { + throw new LauncherUpdateException( + "The target-local self-update transaction could not be reclaimed."); + } + + string incoming = Path.Combine(swap, "incoming"); + Directory.CreateDirectory(incoming); + string payload = GetPayloadDirectory(plan.TransactionId); + foreach (InstalledFileRecord file in plan.Files) + { + cancellationToken.ThrowIfCancellationRequested(); + string source = ClientVersionStore.ResolveContained(payload, file.Path); + string destination = ClientVersionStore.ResolveContained(incoming, file.Path); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + await CopyFileDurablyAsync(source, destination, cancellationToken) + .ConfigureAwait(false); + if (OperatingSystem.IsLinux() && file.UnixMode != 0) + { + File.SetUnixFileMode(destination, (UnixFileMode)file.UnixMode); + } + + await VerifyFileAsync( + incoming, + file, + "Target-local incoming launcher", + cancellationToken) + .ConfigureAwait(false); + } + + var ownership = new LauncherBinaryInstallRecord( + LauncherBinaryInstallRecord.CurrentSchemaVersion, + plan.Version, + plan.Rid, + plan.Files); + await AtomicJsonFile.WriteAsync( + Path.Combine(incoming, InstallRecordFileName), + ownership, + SerializerOptions, + cancellationToken) + .ConfigureAwait(false); + ClientVersionStore.RejectReparseTree(swap); + + string[] actual = Directory.EnumerateFiles( + incoming, + "*", + SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(incoming, path).Replace('\\', '/')) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + string[] expected = apply + .Where(entry => entry.Operation == SelfUpdateApplyOperation.Install) + .Select(entry => entry.Path) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + if (!actual.SequenceEqual(expected, StringComparer.Ordinal)) + { + throw new LauncherUpdateException( + "The target-local self-update incoming tree is incomplete."); + } + + var completed = new List(apply.Count); + foreach (SelfUpdateApplyEntry entry in apply) + { + if (entry.Operation == SelfUpdateApplyOperation.Remove) + { + completed.Add(entry); + continue; + } + + JournalFileMetadata replacement = await CaptureRequiredFileMetadataAsync( + ClientVersionStore.ResolveContained(incoming, entry.Path), + $"Target-local incoming launcher file '{entry.Path}'", + cancellationToken) + .ConfigureAwait(false); + completed.Add(entry with + { + ReplacementSha256 = replacement.Sha256, + ReplacementSize = replacement.Size, + ReplacementUnixMode = replacement.UnixMode, + }); + } + + return completed; + } + + private async Task ApplyEntryAsync( + SelfUpdatePlan plan, + SelfUpdateApplyEntry entry, + CancellationToken cancellationToken) + { + string swap = GetTargetTransactionDirectory(plan); + string incoming = Path.Combine(swap, "incoming"); + string backup = Path.Combine(swap, "backup"); + string targetPath = ClientVersionStore.ResolveContained( + plan.TargetDirectory, + entry.Path); + string backupPath = ClientVersionStore.ResolveContained(backup, entry.Path); + string incomingPath = ClientVersionStore.ResolveContained(incoming, entry.Path); + ClientVersionStore.RejectReparseTree(swap); + EnsureExistingParentsSafe(plan.TargetDirectory, targetPath); + EnsureExistingParentsSafe(swap, incomingPath); + EnsureExistingParentsSafe(swap, backupPath); + EnsurePathMissing(backupPath, $"Self-update backup '{entry.Path}'"); + + if (entry.HadOriginal) + { + await VerifyPriorFileAsync(entry, targetPath, cancellationToken) + .ConfigureAwait(false); + } + else + { + EnsurePathMissing(targetPath, $"Self-update target '{entry.Path}'"); + } + + if (entry.Operation == SelfUpdateApplyOperation.Remove) + { + EnsureSafeParent(swap, backupPath); + File.Move(targetPath, backupPath); + return; + } + + await VerifyReplacementFileAsync(entry, incomingPath, cancellationToken) + .ConfigureAwait(false); + EnsureSafeParent(swap, backupPath); + + if (entry.HadOriginal) + { + File.Replace(incomingPath, targetPath, backupPath, ignoreMetadataErrors: true); + } + else + { + File.Move(incomingPath, targetPath); + } + } + + private async Task RollbackApplyingAsync( + SelfUpdatePlan plan, + CancellationToken cancellationToken) + { + if (plan.State != SelfUpdatePlanState.Applying || plan.Apply is null) + { + throw new LauncherUpdateException("The self-update rollback journal is missing."); + } + + string swap = GetTargetTransactionDirectory(plan); + IReadOnlyList actions = await PreflightRollbackAsync( + plan, + cancellationToken) + .ConfigureAwait(false); + foreach (RollbackAction action in actions) + { + cancellationToken.ThrowIfCancellationRequested(); + ClientVersionStore.RejectReparseTree(swap); + EnsureExistingParentsSafe(plan.TargetDirectory, action.TargetPath); + EnsureExistingParentsSafe(swap, action.BackupPath); + EnsureExistingParentsSafe(swap, action.DiscardPath); + if (action.Entry.Operation == SelfUpdateApplyOperation.Remove) + { + EnsureSafeParent(plan.TargetDirectory, action.TargetPath); + File.Move(action.BackupPath, action.TargetPath); + continue; + } + + EnsureSafeParent(swap, action.DiscardPath); + if (action.Entry.HadOriginal) + { + File.Replace( + action.BackupPath, + action.TargetPath, + action.DiscardPath, + ignoreMetadataErrors: true); + } + else + { + File.Move(action.TargetPath, action.DiscardPath); + } + } + + await VerifyRestoredPriorAsync(plan, plan.TargetDirectory, cancellationToken) + .ConfigureAwait(false); + plan = plan with + { + State = SelfUpdatePlanState.RolledBack, + }; + await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false); + SafeZipExtractor.TryDeleteDirectory(swap); + return plan; + } + + private async Task> PreflightRollbackAsync( + SelfUpdatePlan plan, + CancellationToken cancellationToken) + { + string swap = GetTargetTransactionDirectory(plan); + if (!Directory.Exists(swap)) + { + throw new LauncherUpdateException( + "The target-local self-update rollback transaction is missing."); + } + + ClientVersionStore.RejectReparseTree(swap); + ValidateRollbackTree(plan, swap); + string incoming = Path.Combine(swap, "incoming"); + string backup = Path.Combine(swap, "backup"); + string discard = Path.Combine(swap, "rollback-discard"); + var actions = new List(); + foreach (SelfUpdateApplyEntry entry in plan.Apply!.Reverse()) + { + cancellationToken.ThrowIfCancellationRequested(); + string targetPath = ClientVersionStore.ResolveContained( + plan.TargetDirectory, + entry.Path); + string incomingPath = ClientVersionStore.ResolveContained(incoming, entry.Path); + string backupPath = ClientVersionStore.ResolveContained(backup, entry.Path); + string discardPath = ClientVersionStore.ResolveContained(discard, entry.Path); + EnsureExistingParentsSafe(plan.TargetDirectory, targetPath); + EnsureExistingParentsSafe(swap, incomingPath); + EnsureExistingParentsSafe(swap, backupPath); + EnsureExistingParentsSafe(swap, discardPath); + + JournalFileMetadata? target = await CaptureOptionalFileMetadataAsync( + targetPath, + $"Rollback target '{entry.Path}'", + cancellationToken) + .ConfigureAwait(false); + JournalFileMetadata? incomingFile = await CaptureOptionalFileMetadataAsync( + incomingPath, + $"Rollback incoming file '{entry.Path}'", + cancellationToken) + .ConfigureAwait(false); + JournalFileMetadata? backupFile = await CaptureOptionalFileMetadataAsync( + backupPath, + $"Rollback backup file '{entry.Path}'", + cancellationToken) + .ConfigureAwait(false); + JournalFileMetadata? discardedFile = await CaptureOptionalFileMetadataAsync( + discardPath, + $"Rollback discard file '{entry.Path}'", + cancellationToken) + .ConfigureAwait(false); + + if (entry.Operation == SelfUpdateApplyOperation.Remove) + { + RequireMissing(incomingFile, entry.Path, "incoming"); + RequireMissing(discardedFile, entry.Path, "discard"); + if (backupFile is not null && target is null) + { + RequirePriorMetadata(entry, backupFile, "rollback backup"); + actions.Add(new RollbackAction( + entry, + targetPath, + backupPath, + discardPath)); + continue; + } + + if (backupFile is null && target is not null) + { + RequirePriorMetadata(entry, target, "restored rollback target"); + continue; + } + + throw AmbiguousRollback(entry.Path); + } + + if (entry.HadOriginal) + { + if (backupFile is not null + && target is not null + && incomingFile is null + && discardedFile is null) + { + RequirePriorMetadata(entry, backupFile, "rollback backup"); + RequireReplacementMetadata(entry, target, "applied rollback target"); + actions.Add(new RollbackAction( + entry, + targetPath, + backupPath, + discardPath)); + continue; + } + + if (backupFile is null && target is not null) + { + RequirePriorMetadata(entry, target, "restored rollback target"); + if (incomingFile is not null && discardedFile is null) + { + RequireReplacementMetadata( + entry, + incomingFile, + "unapplied rollback incoming file"); + continue; + } + + if (incomingFile is null && discardedFile is not null) + { + RequireReplacementMetadata( + entry, + discardedFile, + "completed rollback discard"); + continue; + } + } + + throw AmbiguousRollback(entry.Path); + } + + RequireMissing(backupFile, entry.Path, "backup"); + if (target is not null + && incomingFile is null + && discardedFile is null) + { + RequireReplacementMetadata(entry, target, "applied rollback target"); + actions.Add(new RollbackAction( + entry, + targetPath, + backupPath, + discardPath)); + continue; + } + + if (target is null && incomingFile is not null && discardedFile is null) + { + RequireReplacementMetadata( + entry, + incomingFile, + "unapplied rollback incoming file"); + continue; + } + + if (target is null && incomingFile is null && discardedFile is not null) + { + RequireReplacementMetadata( + entry, + discardedFile, + "completed rollback discard"); + continue; + } + + throw AmbiguousRollback(entry.Path); + } + + return actions; + } + + private static void ValidateRollbackTree(SelfUpdatePlan plan, string swap) + { + RequireTransactionContainer(Path.Combine(swap, "incoming"), required: true); + RequireTransactionContainer(Path.Combine(swap, "backup"), required: false); + RequireTransactionContainer( + Path.Combine(swap, "rollback-discard"), + required: false); + var allowed = new HashSet(StringComparer.Ordinal) + { + "incoming", + }; + foreach (SelfUpdateApplyEntry entry in plan.Apply!) + { + if (entry.Operation == SelfUpdateApplyOperation.Install) + { + AddAllowedTreePath(allowed, "incoming", entry.Path); + AddAllowedTreePath(allowed, "rollback-discard", entry.Path); + } + + if (entry.HadOriginal) + { + AddAllowedTreePath(allowed, "backup", entry.Path); + } + } + + foreach (string path in Directory.EnumerateFileSystemEntries( + swap, + "*", + SearchOption.AllDirectories)) + { + string relative = Path.GetRelativePath(swap, path).Replace('\\', '/'); + if (!allowed.Contains(relative)) + { + throw new LauncherUpdateException( + $"The rollback transaction contains unrecorded path '{relative}'."); + } + } + } + + private static void RequireTransactionContainer(string path, bool required) + { + try + { + FileAttributes attributes = File.GetAttributes(path); + if ((attributes & FileAttributes.Directory) == 0 + || (attributes & FileAttributes.ReparsePoint) != 0) + { + throw new LauncherUpdateException( + $"Rollback container '{Path.GetFileName(path)}' is not a safe directory."); + } + } + catch (FileNotFoundException) when (!required) + { + } + catch (DirectoryNotFoundException) when (!required) + { + } + catch (FileNotFoundException) + { + throw new LauncherUpdateException( + $"Required rollback container '{Path.GetFileName(path)}' is missing."); + } + catch (DirectoryNotFoundException) + { + throw new LauncherUpdateException( + $"Required rollback container '{Path.GetFileName(path)}' is missing."); + } + } + + private static void AddAllowedTreePath( + HashSet allowed, + string container, + string relativePath) + { + allowed.Add(container); + string current = container; + foreach (string segment in relativePath.Split('/')) + { + current += "/" + segment; + allowed.Add(current); + } + } + + private static LauncherUpdateException AmbiguousRollback(string path) => new( + $"Rollback state for '{path}' is corrupt or ambiguous; transaction evidence was preserved."); + + private static void RequireMissing( + JournalFileMetadata? metadata, + string path, + string location) + { + if (metadata is not null) + { + throw new LauncherUpdateException( + $"Rollback {location} for '{path}' is unexpected; transaction evidence was preserved."); + } + } + + private static async Task VerifyRestoredPriorAsync( + SelfUpdatePlan plan, + string targetDirectory, + CancellationToken cancellationToken) + { + if (plan.Apply is null) + { + throw new LauncherUpdateException("The rollback receipt is missing its apply journal."); + } + + foreach (SelfUpdateApplyEntry entry in plan.Apply) + { + cancellationToken.ThrowIfCancellationRequested(); + string targetPath = ClientVersionStore.ResolveContained(targetDirectory, entry.Path); + EnsureExistingParentsSafe(targetDirectory, targetPath); + if (entry.HadOriginal) + { + await VerifyPriorFileAsync(entry, targetPath, cancellationToken) + .ConfigureAwait(false); + } + else + { + EnsurePathMissing(targetPath, $"Restored rollback target '{entry.Path}'"); + } + } + } + + private static async Task VerifyPriorFileAsync( + SelfUpdateApplyEntry entry, + string path, + CancellationToken cancellationToken) + { + JournalFileMetadata actual = await CaptureRequiredFileMetadataAsync( + path, + $"Prior launcher file '{entry.Path}'", + cancellationToken) + .ConfigureAwait(false); + RequirePriorMetadata(entry, actual, "prior launcher file"); + } + + private static async Task VerifyReplacementFileAsync( + SelfUpdateApplyEntry entry, + string path, + CancellationToken cancellationToken) + { + JournalFileMetadata actual = await CaptureRequiredFileMetadataAsync( + path, + $"Replacement launcher file '{entry.Path}'", + cancellationToken) + .ConfigureAwait(false); + RequireReplacementMetadata(entry, actual, "replacement launcher file"); + } + + private static void RequirePriorMetadata( + SelfUpdateApplyEntry entry, + JournalFileMetadata actual, + string description) => + RequireMetadata( + entry.Path, + description, + actual, + entry.PriorSha256, + entry.PriorSize, + entry.PriorUnixMode); + + private static void RequireReplacementMetadata( + SelfUpdateApplyEntry entry, + JournalFileMetadata actual, + string description) => + RequireMetadata( + entry.Path, + description, + actual, + entry.ReplacementSha256, + entry.ReplacementSize, + entry.ReplacementUnixMode); + + private static void RequireMetadata( + string path, + string description, + JournalFileMetadata actual, + string? expectedSha256, + long? expectedSize, + int? expectedUnixMode) + { + if (!string.Equals(actual.Sha256, expectedSha256, StringComparison.OrdinalIgnoreCase) + || actual.Size != expectedSize + || actual.UnixMode != expectedUnixMode) + { + throw new LauncherUpdateException( + $"The {description} '{path}' failed its rollback integrity check; " + + "transaction evidence was preserved."); + } + } + + private static async Task CaptureRequiredFileMetadataAsync( + string path, + string description, + CancellationToken cancellationToken) => + await CaptureOptionalFileMetadataAsync(path, description, cancellationToken) + .ConfigureAwait(false) + ?? throw new LauncherUpdateException($"{description} is missing."); + + private static async Task CaptureOptionalFileMetadataAsync( + string path, + string description, + CancellationToken cancellationToken) + { + FileAttributes attributes; + try + { + attributes = File.GetAttributes(path); + } + catch (FileNotFoundException) + { + return null; + } + catch (DirectoryNotFoundException) + { + return null; + } + + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0) + { + throw new LauncherUpdateException($"{description} is a directory or reparse point."); + } + + var before = new FileInfo(path); + long size = before.Length; + int unixMode = OperatingSystem.IsLinux() + ? (int)File.GetUnixFileMode(path) & 0x1FF + : 0; + string sha256 = await Integrity.FileIntegrity.ComputeSha256HexAsync( + path, + cancellationToken) + .ConfigureAwait(false); + var after = new FileInfo(path); + after.Refresh(); + if (!after.Exists + || (after.Attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0 + || after.Length != size + || (OperatingSystem.IsLinux() + && ((int)File.GetUnixFileMode(path) & 0x1FF) != unixMode)) + { + throw new LauncherUpdateException($"{description} changed while it was measured."); + } + + return new JournalFileMetadata(sha256, size, unixMode); + } + + private static void EnsurePathMissing(string path, string description) + { + try + { + _ = File.GetAttributes(path); + } + catch (FileNotFoundException) + { + return; + } + catch (DirectoryNotFoundException) + { + return; + } + + throw new LauncherUpdateException($"{description} already exists."); + } + + private async Task VerifyPayloadAsync( + SelfUpdatePlan plan, + CancellationToken cancellationToken) + { + string payload = GetPayloadDirectory(plan.TransactionId); + if (!Directory.Exists(payload)) + { + throw new LauncherUpdateException("The staged launcher payload is missing."); + } + + ClientVersionStore.RejectReparseTree(payload); + string[] actual = Directory.EnumerateFiles( + payload, + "*", + SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(payload, path).Replace('\\', '/')) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + string[] expected = plan.Files + .Select(file => file.Path) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + if (!actual.SequenceEqual(expected, StringComparer.Ordinal)) + { + throw new LauncherUpdateException( + "The staged launcher contains missing or unrecorded files."); + } + + foreach (InstalledFileRecord file in plan.Files) + { + await VerifyFileAsync(payload, file, "Staged launcher", cancellationToken) + .ConfigureAwait(false); + } + } + + private static async Task VerifyAppliedTargetsAsync( + SelfUpdatePlan plan, + string targetDirectory, + CancellationToken cancellationToken) + { + foreach (InstalledFileRecord file in plan.Files) + { + await VerifyFileAsync( + targetDirectory, + file, + "Applied launcher", + cancellationToken) + .ConfigureAwait(false); + } + + if (plan.Apply is not null) + { + foreach (SelfUpdateApplyEntry obsolete in plan.Apply.Where(entry => + entry.Operation == SelfUpdateApplyOperation.Remove)) + { + string path = ClientVersionStore.ResolveContained( + targetDirectory, + obsolete.Path); + if (File.Exists(path) || Directory.Exists(path)) + { + throw new LauncherUpdateException( + $"Obsolete launcher file '{obsolete.Path}' remains after apply."); + } + } + } + } + + private static async Task VerifyFileAsync( + string root, + InstalledFileRecord file, + string description, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + string path = ClientVersionStore.ResolveContained(root, file.Path); + EnsureSafeParent(root, path); + var info = new FileInfo(path); + if (!info.Exists + || (info.Attributes & FileAttributes.ReparsePoint) != 0 + || info.Length != file.Size) + { + throw new LauncherUpdateException( + $"{description} file '{file.Path}' is missing, linked, or corrupt."); + } + + string sha256 = await Integrity.FileIntegrity.ComputeSha256HexAsync( + path, + cancellationToken) + .ConfigureAwait(false); + if (!string.Equals(sha256, file.Sha256, StringComparison.OrdinalIgnoreCase)) + { + throw new LauncherUpdateException( + $"{description} file '{file.Path}' SHA-256 is corrupt."); + } + + if (OperatingSystem.IsLinux() + && ((int)File.GetUnixFileMode(path) & 0x1FF) != file.UnixMode) + { + throw new LauncherUpdateException( + $"{description} file '{file.Path}' mode is corrupt."); + } + } + + private static async Task ReadAndVerifyInstallRecordAsync( + string targetDirectory, + string rid, + CancellationToken cancellationToken) + { + string path = Path.Combine(targetDirectory, InstallRecordFileName); + if (!File.Exists(path)) + { + return null; + } + + if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + { + throw new LauncherUpdateException("The launcher ownership record is linked."); + } + + byte[] bytes = await File.ReadAllBytesAsync(path, cancellationToken) + .ConfigureAwait(false); + LauncherBinaryInstallRecord? record = ClientVersionStore.ParseStrict( + bytes, + SerializerOptions); + ValidateInstallRecord(record, rid); + foreach (InstalledFileRecord file in record!.Files) + { + await VerifyFileAsync( + targetDirectory, + file, + "Owned launcher", + cancellationToken) + .ConfigureAwait(false); + } + + return record; + } + + private static async Task VerifyInstalledOwnershipMatchesPlanAsync( + SelfUpdatePlan plan, + string targetDirectory, + CancellationToken cancellationToken) + { + LauncherBinaryInstallRecord? record = await ReadAndVerifyInstallRecordAsync( + targetDirectory, + plan.Rid, + cancellationToken) + .ConfigureAwait(false); + if (record is null + || !string.Equals(record.Version, plan.Version, StringComparison.Ordinal) + || !record.Files.SequenceEqual(plan.Files)) + { + throw new LauncherUpdateException( + "The installed launcher ownership record does not match the pending plan."); + } + } + + private async Task WritePlanAsync( + SelfUpdatePlan plan, + CancellationToken cancellationToken) + { + ValidatePlan(plan, plan.TargetDirectory); + await AtomicJsonFile.WriteAsync( + PendingPlanPath, + plan, + SerializerOptions, + cancellationToken) + .ConfigureAwait(false); + } + + private void ValidatePlan(SelfUpdatePlan plan, string expectedTargetDirectory) + { + if (plan.SchemaVersion != SelfUpdatePlan.CurrentSchemaVersion) + { + throw new LauncherUpdateException( + $"Self-update schema version {plan.SchemaVersion} is not supported."); + } + + RequireTransactionId(plan.TransactionId); + if (!LauncherVersion.TryParse(plan.Version, out _) + || !LauncherRuntimeIdentity.IsValidRid(plan.Rid) + || !ReleaseManifestClient.IsSha256(plan.ArchiveSha256) + || plan.ArchiveSize <= 0 + || plan.ArchiveSize > ReleaseManifestClient.MaximumArtifactBytes) + { + throw new LauncherUpdateException("The self-update plan metadata is invalid."); + } + + string target = NormalizeTargetDirectory(plan.TargetDirectory); + if (!PathsEqual(target, expectedTargetDirectory)) + { + throw new LauncherUpdateException( + "The self-update target does not match the running launcher directory."); + } + + ValidateFileRecords(plan.Files, "self-update file list"); + var newPaths = new HashSet( + plan.Files.Select(file => file.Path), + StringComparer.OrdinalIgnoreCase); + if (newPaths.Contains(InstallRecordFileName) + || !newPaths.Contains(GetLauncherFileName(plan.Rid))) + { + throw new LauncherUpdateException( + "The self-update file list has a reserved path or lacks the launcher executable."); + } + + if (plan.State == SelfUpdatePlanState.Staged && plan.Apply is not null + || plan.State != SelfUpdatePlanState.Staged && plan.Apply is null) + { + throw new LauncherUpdateException( + "The self-update apply journal does not match its state."); + } + + if (plan.Apply is not null) + { + var applyPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + string? prior = null; + foreach (SelfUpdateApplyEntry entry in plan.Apply) + { + if (!ClientVersionStore.IsNormalizedRelative(entry.Path) + || !applyPaths.Add(entry.Path) + || !Enum.IsDefined(entry.Operation) + || (entry.Operation == SelfUpdateApplyOperation.Remove + && !entry.HadOriginal) + || entry.HadOriginal != ( + ReleaseManifestClient.IsSha256(entry.PriorSha256) + && entry.PriorSize is >= 0 + && entry.PriorUnixMode is >= 0 and <= 0x1FF) + || entry.HadOriginal == ( + entry.PriorSha256 is null + && entry.PriorSize is null + && entry.PriorUnixMode is null) + || (entry.Operation == SelfUpdateApplyOperation.Install) != ( + ReleaseManifestClient.IsSha256(entry.ReplacementSha256) + && entry.ReplacementSize is >= 0 + && entry.ReplacementUnixMode is >= 0 and <= 0x1FF) + || (entry.Operation == SelfUpdateApplyOperation.Install) == ( + entry.ReplacementSha256 is null + && entry.ReplacementSize is null + && entry.ReplacementUnixMode is null) + || (prior is not null + && string.Compare(prior, entry.Path, StringComparison.Ordinal) >= 0)) + { + throw new LauncherUpdateException( + "The self-update apply journal is invalid, duplicated, or unsorted."); + } + + prior = entry.Path; + } + + foreach (string required in newPaths.Append(InstallRecordFileName)) + { + SelfUpdateApplyEntry? entry = plan.Apply.FirstOrDefault(candidate => + string.Equals(candidate.Path, required, StringComparison.OrdinalIgnoreCase)); + if (entry?.Operation != SelfUpdateApplyOperation.Install) + { + throw new LauncherUpdateException( + "The self-update apply journal does not install every new owned file."); + } + } + + if (plan.Apply.Any(entry => + entry.Operation == SelfUpdateApplyOperation.Remove + && (newPaths.Contains(entry.Path) + || string.Equals( + entry.Path, + InstallRecordFileName, + StringComparison.OrdinalIgnoreCase)))) + { + throw new LauncherUpdateException( + "The self-update journal removes a new or reserved file."); + } + } + + string transactionDirectory = GetTransactionDirectory(plan.TransactionId); + if (!IsContained(TransactionsDirectory, transactionDirectory) + || !IsContained(target, GetTargetTransactionDirectory(plan))) + { + throw new LauncherUpdateException("A self-update transaction path escaped."); + } + } + + private static void ValidateInstallRecord(LauncherBinaryInstallRecord? record, string rid) + { + if (record is null + || record.SchemaVersion != LauncherBinaryInstallRecord.CurrentSchemaVersion + || !LauncherVersion.TryParse(record.Version, out _) + || !string.Equals(record.Rid, rid, StringComparison.Ordinal)) + { + throw new LauncherUpdateException("The launcher ownership record is invalid."); + } + + ValidateFileRecords(record.Files, "launcher ownership file list"); + if (record.Files.Any(file => string.Equals( + file.Path, + InstallRecordFileName, + StringComparison.OrdinalIgnoreCase)) + || !record.Files.Any(file => string.Equals( + file.Path, + GetLauncherFileName(rid), + StringComparison.OrdinalIgnoreCase))) + { + throw new LauncherUpdateException( + "The launcher ownership record contains its reserved metadata path " + + "or lacks the canonical launcher executable."); + } + } + + private static void ValidateFileRecords( + IReadOnlyList? files, + string description) + { + if (files is null || files.Count == 0) + { + throw new LauncherUpdateException($"The {description} is empty."); + } + + var paths = new HashSet(StringComparer.OrdinalIgnoreCase); + string? prior = null; + foreach (InstalledFileRecord file in files) + { + if (!ClientVersionStore.IsNormalizedRelative(file.Path) + || !paths.Add(file.Path) + || !ReleaseManifestClient.IsSha256(file.Sha256) + || file.Size < 0 + || file.UnixMode is < 0 or > 0x1FF + || (prior is not null + && string.Compare(prior, file.Path, StringComparison.Ordinal) >= 0)) + { + throw new LauncherUpdateException( + $"The {description} is invalid, duplicated, or unsorted."); + } + + prior = file.Path; + } + } + + private static async Task CopyFileDurablyAsync( + string source, + string destination, + CancellationToken cancellationToken) + { + await using var input = new FileStream( + source, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 64 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + await using var output = new FileStream( + destination, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 64 * 1024, + FileOptions.Asynchronous | FileOptions.WriteThrough); + await input.CopyToAsync(output, 64 * 1024, cancellationToken) + .ConfigureAwait(false); + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + output.Flush(flushToDisk: true); + } + + private static string NormalizeTargetDirectory(string targetDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(targetDirectory); + if (!Path.IsPathFullyQualified(targetDirectory)) + { + throw new LauncherUpdateException( + "The self-update target directory must be absolute."); + } + + string target = Path.TrimEndingDirectorySeparator(Path.GetFullPath(targetDirectory)); + if (!Directory.Exists(target) + || (File.GetAttributes(target) & FileAttributes.ReparsePoint) != 0) + { + throw new LauncherUpdateException( + "The self-update target directory is missing or is a reparse point."); + } + + return target; + } + + private static void EnsureSafeParent(string root, string filePath) + { + string? parent = Path.GetDirectoryName(filePath); + if (parent is null) + { + throw new LauncherUpdateException("A self-update target has no parent."); + } + + EnsureExistingParentsSafe(root, filePath); + Directory.CreateDirectory(parent); + EnsureExistingParentsSafe(root, filePath); + } + + private static void EnsureExistingParentsSafe(string root, string filePath) + { + string? parent = Path.GetDirectoryName(filePath); + if (parent is null) + { + throw new LauncherUpdateException("A self-update target has no parent."); + } + + for (var directory = new DirectoryInfo(parent); + directory is not null && IsContained(root, directory.FullName); + directory = directory.Parent) + { + FileAttributes attributes; + try + { + attributes = File.GetAttributes(directory.FullName); + } + catch (FileNotFoundException) + { + continue; + } + catch (DirectoryNotFoundException) + { + continue; + } + + if ((attributes & FileAttributes.Directory) == 0 + || (attributes & FileAttributes.ReparsePoint) != 0) + { + throw new LauncherUpdateException( + $"Self-update target parent '{directory.FullName}' is not a safe directory."); + } + + if (PathsEqual(directory.FullName, root)) + { + break; + } + } + } + + private static bool IsContained(string root, string path) + { + string fullRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)); + string fullPath = Path.GetFullPath(path); + return PathsEqual(fullRoot, fullPath) + || fullPath.StartsWith( + fullRoot + Path.DirectorySeparatorChar, + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal); + } + + private static bool PathsEqual(string left, string right) => + string.Equals( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)), + Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)), + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal); + + private static void RequireTransactionId(string transactionId) + { + if (transactionId.Length != 32 + || !Guid.TryParseExact(transactionId, "N", out Guid parsed) + || !string.Equals(parsed.ToString("N"), transactionId, StringComparison.Ordinal)) + { + throw new LauncherUpdateException("The self-update transaction id is invalid."); + } + } + + private void CleanupDataResidue(string? keepTransactionId) + { + if (Directory.Exists(TransactionsDirectory)) + { + foreach (string directory in Directory.EnumerateDirectories( + TransactionsDirectory, + "*", + SearchOption.TopDirectoryOnly)) + { + string name = Path.GetFileName(directory); + if (IsCanonicalTransactionId(name) + && !string.Equals(name, keepTransactionId, StringComparison.Ordinal)) + { + SafeZipExtractor.TryDeleteDirectory(directory); + } + } + } + + if (!Directory.Exists(RootDirectory)) + { + return; + } + + foreach (string temporary in Directory.EnumerateFiles( + RootDirectory, + ".pending.json.*.tmp", + SearchOption.TopDirectoryOnly)) + { + string name = Path.GetFileName(temporary); + const string prefix = ".pending.json."; + const string suffix = ".tmp"; + if (name.Length == prefix.Length + 32 + suffix.Length + && name.StartsWith(prefix, StringComparison.Ordinal) + && name.EndsWith(suffix, StringComparison.Ordinal) + && IsCanonicalTransactionId(name.Substring(prefix.Length, 32))) + { + VerifiedArtifactDownloader.TryDelete(temporary); + } + } + } + + private static void CleanupTargetResidue( + string targetDirectory, + string? keepTransactionId) + { + foreach (string directory in Directory.EnumerateDirectories( + targetDirectory, + TargetTransactionPrefix + "*", + SearchOption.TopDirectoryOnly)) + { + string name = Path.GetFileName(directory); + string transaction = name[TargetTransactionPrefix.Length..]; + if (name.Length == TargetTransactionPrefix.Length + 32 + && IsCanonicalTransactionId(transaction) + && !string.Equals(transaction, keepTransactionId, StringComparison.Ordinal)) + { + SafeZipExtractor.TryDeleteDirectory(directory); + } + } + } + + private bool HasReclaimableResidue( + string? keepDataTransactionId, + string targetDirectory, + string? keepTargetTransactionId) + { + bool data = Directory.Exists(TransactionsDirectory) + && Directory.EnumerateDirectories( + TransactionsDirectory, + "*", + SearchOption.TopDirectoryOnly) + .Select(Path.GetFileName) + .Any(name => name is not null + && IsCanonicalTransactionId(name) + && !string.Equals( + name, + keepDataTransactionId, + StringComparison.Ordinal)); + bool target = Directory.EnumerateDirectories( + targetDirectory, + TargetTransactionPrefix + "*", + SearchOption.TopDirectoryOnly) + .Select(Path.GetFileName) + .Any(name => name is not null + && name.Length == TargetTransactionPrefix.Length + 32 + && IsCanonicalTransactionId(name[TargetTransactionPrefix.Length..]) + && !string.Equals( + name[TargetTransactionPrefix.Length..], + keepTargetTransactionId, + StringComparison.Ordinal)); + return data || target; + } + + private static bool IsCanonicalTransactionId(string value) => + value.Length == 32 + && Guid.TryParseExact(value, "N", out Guid parsed) + && string.Equals(parsed.ToString("N"), value, StringComparison.Ordinal); +} diff --git a/src/AcDream.Launcher.Core/Updates/LauncherUpdater.cs b/src/AcDream.Launcher.Core/Updates/LauncherUpdater.cs new file mode 100644 index 00000000..01d7676b --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/LauncherUpdater.cs @@ -0,0 +1,454 @@ +namespace AcDream.Launcher.Core.Updates; + +public enum LauncherUpdatePhase +{ + Idle, + Checking, + DownloadingClient, + ExtractingClient, + ActivatingClient, + DownloadingLauncher, + StagingLauncher, + RollingBack, + Completed, + Cancelled, + Failed, +} + +public sealed record LauncherUpdateProgress( + LauncherUpdatePhase Phase, + string Status, + long Completed = 0, + long Total = 0) +{ + public double Percent => Total <= 0 + ? 0 + : Math.Clamp(Completed * 100d / Total, 0, 100); +} + +public sealed record LauncherUpdateCheckResult( + ReleaseManifest Manifest, + string Rid, + LauncherVersion LauncherVersion, + LauncherVersion? InstalledClientVersion, + bool IsClientUpdateAvailable, + bool IsLauncherUpdateAvailable, + bool IsLauncherMinimumSatisfied, + string Status); + +public interface ILauncherUpdater +{ + ClientVersionResolution CurrentClient { get; } + + Task InitializeAsync( + CancellationToken cancellationToken = default); + + Task CheckAsync( + CancellationToken cancellationToken = default); + + Task InstallClientAsync( + LauncherUpdateCheckResult check, + IProgress? progress = null, + CancellationToken cancellationToken = default); + + Task StageLauncherAsync( + LauncherUpdateCheckResult check, + IProgress? progress = null, + CancellationToken cancellationToken = default); + + Task RollbackClientAsync( + IProgress? progress = null, + CancellationToken cancellationToken = default); +} + +/// +/// Canonical LA10 update transaction. It holds the cross-process exclusive +/// barrier for recovery/download/extraction/promotion/pointer publication and +/// leaves LA9's verified DAT/pak record untouched. +/// +public sealed class LauncherUpdater : ILauncherUpdater +{ + private readonly IReleaseManifestClient _manifestClient; + private readonly ClientVersionStore _versions; + private readonly LauncherSelfUpdateManager _selfUpdates; + private readonly VerifiedArtifactDownloader _downloader; + private readonly SafeZipExtractor _extractor; + private readonly LauncherVersion _launcherVersion; + private readonly string _rid; + private readonly string _launcherTargetDirectory; + private readonly Func _hasRunningSessions; + private readonly SemaphoreSlim _operationGate = new(1, 1); + + public LauncherUpdater( + IReleaseManifestClient manifestClient, + HttpClient httpClient, + ClientVersionStore versions, + LauncherSelfUpdateManager selfUpdates, + LauncherVersion launcherVersion, + string rid, + string launcherTargetDirectory, + Func? hasRunningSessions = null, + SafeZipExtractor? extractor = null) + { + _manifestClient = manifestClient + ?? throw new ArgumentNullException(nameof(manifestClient)); + _versions = versions ?? throw new ArgumentNullException(nameof(versions)); + _selfUpdates = selfUpdates ?? throw new ArgumentNullException(nameof(selfUpdates)); + _launcherVersion = launcherVersion + ?? throw new ArgumentNullException(nameof(launcherVersion)); + if (!LauncherRuntimeIdentity.IsValidRid(rid)) + { + throw new ArgumentException("RID is invalid.", nameof(rid)); + } + + _rid = rid; + ArgumentException.ThrowIfNullOrWhiteSpace(launcherTargetDirectory); + _launcherTargetDirectory = Path.GetFullPath(launcherTargetDirectory); + _hasRunningSessions = hasRunningSessions ?? (() => false); + _downloader = new VerifiedArtifactDownloader( + httpClient ?? throw new ArgumentNullException(nameof(httpClient))); + _extractor = extractor ?? new SafeZipExtractor(); + } + + public ClientVersionResolution CurrentClient => _versions.CachedResolution; + + public Task InitializeAsync( + CancellationToken cancellationToken = default) => + _versions.LoadAndRecoverAsync(_rid, cancellationToken); + + public async Task CheckAsync( + CancellationToken cancellationToken = default) + { + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ReleaseManifest manifest = await _manifestClient.FetchAsync(cancellationToken) + .ConfigureAwait(false); + _ = manifest.RequireClient(_rid); + _ = manifest.RequireLauncher(_rid); + ClientVersionResolution installed = _versions.CachedResolution; + LauncherVersion? installedVersion = installed.IsVerified + ? installed.Version + : null; + bool clientAvailable = installedVersion is null + || manifest.Version > installedVersion; + bool launcherAvailable = manifest.Version > _launcherVersion; + bool minimumSatisfied = _launcherVersion >= manifest.MinimumLauncherVersion; + string status = BuildCheckStatus( + manifest, + installedVersion, + clientAvailable, + launcherAvailable, + minimumSatisfied); + return new LauncherUpdateCheckResult( + manifest, + _rid, + _launcherVersion, + installedVersion, + clientAvailable, + launcherAvailable, + minimumSatisfied, + status); + } + finally + { + _operationGate.Release(); + } + } + + public async Task InstallClientAsync( + LauncherUpdateCheckResult check, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(check); + ValidateCheck(check); + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + RefuseRunningSessions(); + using UpdateSessionBarrier.ExclusiveLease lease = + _versions.Barrier.AcquireExclusive(); + RefuseRunningSessions(); + ClientVersionResolution current = await _versions + .LoadAndRecoverUnderLeaseAsync(_rid, cancellationToken) + .ConfigureAwait(false); + if (!check.IsLauncherMinimumSatisfied) + { + throw new LauncherUpdateException( + $"Client {check.Manifest.Version} requires launcher " + + $"{check.Manifest.MinimumLauncherVersion} or newer. " + + "Stage the launcher update first."); + } + + if (current.IsVerified + && current.Version is not null + && current.Version >= check.Manifest.Version) + { + Report( + progress, + LauncherUpdatePhase.Completed, + $"Client {current.Version} is already current.", + 1, + 1); + return current; + } + + ReleaseArtifact artifact = check.Manifest.RequireClient(_rid); + Guid transactionId = Guid.NewGuid(); + string staging = _versions.CreateClientStagingDirectory(transactionId); + string archive = Path.Combine( + _versions.AppDirectory, + $".client-download-{transactionId:N}.zip"); + try + { + Report( + progress, + LauncherUpdatePhase.DownloadingClient, + $"Downloading client {check.Manifest.Version}...", + 0, + artifact.Size); + var downloadProgress = new ForwardProgress(value => + Report( + progress, + LauncherUpdatePhase.DownloadingClient, + $"Downloading client {check.Manifest.Version}: " + + $"{value.BytesReceived:N0}/{value.TotalBytes:N0} bytes", + value.BytesReceived, + value.TotalBytes)); + _ = await _downloader.DownloadAsync( + artifact, + archive, + downloadProgress, + cancellationToken) + .ConfigureAwait(false); + + Report( + progress, + LauncherUpdatePhase.ExtractingClient, + "Verifying paths and extracting the client archive..."); + IReadOnlyList files = await _extractor.ExtractAsync( + archive, + staging, + cancellationToken) + .ConfigureAwait(false); + Report( + progress, + LauncherUpdatePhase.ActivatingClient, + $"Atomically activating client {check.Manifest.Version}..."); + ClientVersionResolution result = await _versions + .PromoteAndActivateUnderLeaseAsync( + staging, + check.Manifest.Version, + _rid, + artifact, + files, + cancellationToken) + .ConfigureAwait(false); + Report( + progress, + LauncherUpdatePhase.Completed, + $"Client {check.Manifest.Version} installed and activated.", + 1, + 1); + return result; + } + catch (OperationCanceledException) + { + Report( + progress, + LauncherUpdatePhase.Cancelled, + "Client update cancelled; the active version was not changed."); + throw; + } + catch (Exception ex) + { + Report( + progress, + LauncherUpdatePhase.Failed, + $"Client update failed: {ex.Message}"); + throw; + } + finally + { + VerifiedArtifactDownloader.TryDelete(archive); + SafeZipExtractor.TryDeleteDirectory(staging); + } + } + finally + { + _operationGate.Release(); + } + } + + public async Task StageLauncherAsync( + LauncherUpdateCheckResult check, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(check); + ValidateCheck(check); + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + RefuseRunningSessions(); + if (check.Manifest.Version <= _launcherVersion) + { + throw new LauncherUpdateException( + $"Launcher {_launcherVersion} is already current."); + } + + Report( + progress, + LauncherUpdatePhase.DownloadingLauncher, + $"Downloading launcher {check.Manifest.Version}..."); + var downloadProgress = new ForwardProgress(value => + Report( + progress, + LauncherUpdatePhase.DownloadingLauncher, + $"Downloading launcher {check.Manifest.Version}: " + + $"{value.BytesReceived:N0}/{value.TotalBytes:N0} bytes", + value.BytesReceived, + value.TotalBytes)); + try + { + SelfUpdateStageResult result = await _selfUpdates.StageAsync( + check.Manifest, + _rid, + _launcherTargetDirectory, + downloadProgress, + cancellationToken) + .ConfigureAwait(false); + Report( + progress, + LauncherUpdatePhase.StagingLauncher, + result.Status, + 1, + 1); + return result; + } + catch (OperationCanceledException) + { + Report( + progress, + LauncherUpdatePhase.Cancelled, + "Launcher update staging cancelled."); + throw; + } + catch (Exception ex) + { + Report( + progress, + LauncherUpdatePhase.Failed, + $"Launcher update staging failed: {ex.Message}"); + throw; + } + } + finally + { + _operationGate.Release(); + } + } + + public async Task RollbackClientAsync( + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + RefuseRunningSessions(); + Report( + progress, + LauncherUpdatePhase.RollingBack, + "Verifying and activating the previous client version..."); + ClientVersionResolution result = await _versions.RollbackAsync( + _rid, + cancellationToken) + .ConfigureAwait(false); + Report( + progress, + LauncherUpdatePhase.Completed, + $"Rolled back to client {result.Version}.", + 1, + 1); + return result; + } + finally + { + _operationGate.Release(); + } + } + + private void ValidateCheck(LauncherUpdateCheckResult check) + { + if (!string.Equals(check.Rid, _rid, StringComparison.Ordinal) + || !check.LauncherVersion.Equals(_launcherVersion)) + { + throw new LauncherUpdateException( + "The update check belongs to a different launcher runtime."); + } + + _ = check.Manifest.RequireClient(_rid); + _ = check.Manifest.RequireLauncher(_rid); + } + + private void RefuseRunningSessions() + { + if (_hasRunningSessions()) + { + throw new LauncherUpdateException( + "Stop every launcher session before installing or rolling back an update."); + } + } + + private static string BuildCheckStatus( + ReleaseManifest manifest, + LauncherVersion? installed, + bool clientAvailable, + bool launcherAvailable, + bool minimumSatisfied) + { + if (!minimumSatisfied) + { + return $"Release {manifest.Version} requires launcher " + + $"{manifest.MinimumLauncherVersion} or newer."; + } + + if (clientAvailable && launcherAvailable) + { + return $"Client and launcher {manifest.Version} are available."; + } + + if (clientAvailable) + { + return installed is null + ? $"Client {manifest.Version} is available for installation." + : $"Client update {installed} -> {manifest.Version} is available."; + } + + if (launcherAvailable) + { + return $"Launcher {manifest.Version} is available."; + } + + return "Client and launcher are up to date."; + } + + private static void Report( + IProgress? progress, + LauncherUpdatePhase phase, + string status, + long completed = 0, + long total = 0) => + progress?.Report(new LauncherUpdateProgress( + phase, + status, + completed, + total)); + + private sealed class ForwardProgress(Action callback) : IProgress + { + public void Report(T value) => callback(value); + } +} diff --git a/src/AcDream.Launcher.Core/Updates/LauncherVersion.cs b/src/AcDream.Launcher.Core/Updates/LauncherVersion.cs new file mode 100644 index 00000000..bf152c08 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/LauncherVersion.cs @@ -0,0 +1,199 @@ +using System.Diagnostics.CodeAnalysis; + +namespace AcDream.Launcher.Core.Updates; + +/// +/// Strict SemVer 2.0 value used by the release feed, client pointer, and +/// self-update plan. Numeric identifiers are compared as digit strings so a +/// maliciously large identifier cannot overflow a fixed-width integer. +/// +public sealed class LauncherVersion : IComparable, IEquatable +{ + private readonly string[] _core; + private readonly string[] _preRelease; + + private LauncherVersion( + string value, + string[] core, + string[] preRelease) + { + Value = value; + _core = core; + _preRelease = preRelease; + } + + public string Value { get; } + + public bool IsPreRelease => _preRelease.Length != 0; + + public static LauncherVersion Parse(string value) + { + if (!TryParse(value, out LauncherVersion? version)) + { + throw new FormatException($"'{value}' is not a strict SemVer 2.0 version."); + } + + return version; + } + + public static bool TryParse( + string? value, + [NotNullWhen(true)] out LauncherVersion? version) + { + version = null; + if (string.IsNullOrEmpty(value) + || value.Length > 128 + || !string.Equals(value, value.Trim(), StringComparison.Ordinal)) + { + return false; + } + + string precedence = value; + int plus = value.IndexOf('+', StringComparison.Ordinal); + if (plus >= 0) + { + if (plus == value.Length - 1 + || value.IndexOf('+', plus + 1) >= 0 + || !ValidIdentifiers(value[(plus + 1)..], numericLeadingZeroRule: false)) + { + return false; + } + + precedence = value[..plus]; + } + + string coreText = precedence; + string[] preRelease = []; + int dash = precedence.IndexOf('-', StringComparison.Ordinal); + if (dash >= 0) + { + if (dash == precedence.Length - 1 + || !ValidIdentifiers(precedence[(dash + 1)..], numericLeadingZeroRule: true)) + { + return false; + } + + coreText = precedence[..dash]; + preRelease = precedence[(dash + 1)..].Split('.'); + } + + string[] core = coreText.Split('.'); + if (core.Length != 3 || core.Any(part => !ValidCoreNumber(part))) + { + return false; + } + + version = new LauncherVersion(value, core, preRelease); + return true; + } + + public int CompareTo(LauncherVersion? other) + { + if (other is null) + { + return 1; + } + + for (int index = 0; index < _core.Length; index++) + { + int comparison = CompareNumeric(_core[index], other._core[index]); + if (comparison != 0) + { + return comparison; + } + } + + if (_preRelease.Length == 0 || other._preRelease.Length == 0) + { + return _preRelease.Length == other._preRelease.Length + ? 0 + : _preRelease.Length == 0 ? 1 : -1; + } + + int shared = Math.Min(_preRelease.Length, other._preRelease.Length); + for (int index = 0; index < shared; index++) + { + string left = _preRelease[index]; + string right = other._preRelease[index]; + bool leftNumeric = IsDigits(left); + bool rightNumeric = IsDigits(right); + int comparison = leftNumeric && rightNumeric + ? CompareNumeric(left, right) + : leftNumeric != rightNumeric + ? leftNumeric ? -1 : 1 + : string.Compare(left, right, StringComparison.Ordinal); + if (comparison != 0) + { + return comparison; + } + } + + return _preRelease.Length.CompareTo(other._preRelease.Length); + } + + public bool Equals(LauncherVersion? other) => + other is not null && CompareTo(other) == 0; + + public override bool Equals(object? obj) => Equals(obj as LauncherVersion); + + public override int GetHashCode() + { + var hash = new HashCode(); + foreach (string part in _core) + { + hash.Add(part, StringComparer.Ordinal); + } + + hash.Add(_preRelease.Length); + foreach (string part in _preRelease) + { + hash.Add(part, StringComparer.Ordinal); + } + + return hash.ToHashCode(); + } + + public override string ToString() => Value; + + public static bool operator >(LauncherVersion left, LauncherVersion right) => + left.CompareTo(right) > 0; + + public static bool operator <(LauncherVersion left, LauncherVersion right) => + left.CompareTo(right) < 0; + + public static bool operator >=(LauncherVersion left, LauncherVersion right) => + left.CompareTo(right) >= 0; + + public static bool operator <=(LauncherVersion left, LauncherVersion right) => + left.CompareTo(right) <= 0; + + private static bool ValidCoreNumber(string value) => + IsDigits(value) && (value.Length == 1 || value[0] != '0'); + + private static bool ValidIdentifiers(string value, bool numericLeadingZeroRule) + { + string[] identifiers = value.Split('.'); + return identifiers.All(identifier => + identifier.Length > 0 + && identifier.All(character => + character is >= '0' and <= '9' + or >= 'A' and <= 'Z' + or >= 'a' and <= 'z' + or '-') + && (!numericLeadingZeroRule + || !IsDigits(identifier) + || identifier.Length == 1 + || identifier[0] != '0')); + } + + private static bool IsDigits(string value) => + value.Length > 0 && value.All(character => character is >= '0' and <= '9'); + + private static int CompareNumeric(string left, string right) + { + int length = left.Length.CompareTo(right.Length); + return length != 0 + ? length + : string.Compare(left, right, StringComparison.Ordinal); + } +} diff --git a/src/AcDream.Launcher.Core/Updates/PortablePathRules.cs b/src/AcDream.Launcher.Core/Updates/PortablePathRules.cs new file mode 100644 index 00000000..1f5ec179 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/PortablePathRules.cs @@ -0,0 +1,38 @@ +namespace AcDream.Launcher.Core.Updates; + +/// +/// Host-independent path rules for payloads that must remain safe when moved +/// between Linux and Windows. Windows device aliases are rejected on every +/// host so a release cannot verify on one platform and become ambiguous on +/// another. +/// +internal static class PortablePathRules +{ + public static bool IsWindowsDeviceName(string segment) + { + ArgumentNullException.ThrowIfNull(segment); + string stem = segment.Split('.')[0]; + if (stem.Equals("CON", StringComparison.OrdinalIgnoreCase) + || stem.Equals("PRN", StringComparison.OrdinalIgnoreCase) + || stem.Equals("AUX", StringComparison.OrdinalIgnoreCase) + || stem.Equals("NUL", StringComparison.OrdinalIgnoreCase) + || stem.Equals("CLOCK$", StringComparison.OrdinalIgnoreCase) + || stem.Equals("CONIN$", StringComparison.OrdinalIgnoreCase) + || stem.Equals("CONOUT$", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (stem.Length != 4 + || (!stem.StartsWith("COM", StringComparison.OrdinalIgnoreCase) + && !stem.StartsWith("LPT", StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + return stem[3] is >= '1' and <= '9' + or '\u00b9' + or '\u00b2' + or '\u00b3'; + } +} diff --git a/src/AcDream.Launcher.Core/Updates/ReleaseManifest.cs b/src/AcDream.Launcher.Core/Updates/ReleaseManifest.cs new file mode 100644 index 00000000..5d922641 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/ReleaseManifest.cs @@ -0,0 +1,37 @@ +namespace AcDream.Launcher.Core.Updates; + +public sealed record ReleaseArtifact(Uri Url, string Sha256, long Size); + +public sealed record ReleaseManifest( + LauncherVersion Version, + LauncherVersion MinimumLauncherVersion, + IReadOnlyDictionary Clients, + IReadOnlyDictionary Launchers) +{ + public const int CurrentSchemaVersion = 1; + + public ReleaseArtifact RequireClient(string rid) => + Clients.TryGetValue(rid, out ReleaseArtifact? artifact) + ? artifact + : throw new LauncherUpdateException( + $"Release {Version} has no client payload for RID '{rid}'."); + + public ReleaseArtifact RequireLauncher(string rid) => + Launchers.TryGetValue(rid, out ReleaseArtifact? artifact) + ? artifact + : throw new LauncherUpdateException( + $"Release {Version} has no launcher payload for RID '{rid}'."); +} + +public sealed class LauncherUpdateException : Exception +{ + public LauncherUpdateException(string message) + : base(message) + { + } + + public LauncherUpdateException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs b/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs new file mode 100644 index 00000000..7736fee5 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs @@ -0,0 +1,451 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AcDream.Launcher.Core.Updates; + +public interface IReleaseManifestClient +{ + Task FetchAsync(CancellationToken cancellationToken = default); +} + +/// +/// Strict, bounded reader for the pinned GitHub Releases manifest. Production +/// construction is pinned and HTTPS-only. The explicitly named process-local +/// feed factory independently revalidates its URI and can admit HTTP only for +/// the loopback operator fixture; it cannot change the production constructor. +/// Redirects are followed manually so every hop is checked before any bytes +/// cross that hop. +/// +public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable +{ + public const string GitHubOwner = "eriknihlen"; + public const string GitHubRepository = "acdream"; + public const int MaximumManifestBytes = 1024 * 1024; + public const long MaximumArtifactBytes = 4L * 1024 * 1024 * 1024; + public const int MaximumRedirects = 5; + + public static Uri ProductionManifestUri { get; } = new( + $"https://github.com/{GitHubOwner}/{GitHubRepository}/releases/latest/download/manifest.json"); + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = false, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + MaxDepth = 16, + }; + + private readonly HttpClient _httpClient; + private readonly Uri _manifestUri; + private readonly bool _allowLoopbackHttp; + + public ReleaseManifestClient(TimeSpan? timeout = null) + : this( + ProductionManifestUri, + allowLoopbackHttp: false, + CreateRedirectDisabledHandler(), + timeout) + { + } + + private ReleaseManifestClient( + Uri manifestUri, + bool allowLoopbackHttp, + HttpMessageHandler handler, + TimeSpan? timeout) + { + ArgumentNullException.ThrowIfNull(manifestUri); + ArgumentNullException.ThrowIfNull(handler); + _manifestUri = manifestUri; + _allowLoopbackHttp = allowLoopbackHttp; + RequireTransport(_manifestUri, "manifest", _allowLoopbackHttp); + _httpClient = new HttpClient(handler, disposeHandler: true) + { + Timeout = timeout ?? TimeSpan.FromSeconds(15), + }; + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1"); + } + + internal static ReleaseManifestClient CreateLoopbackFixture( + Uri manifestUri, + TimeSpan? timeout = null) => new( + manifestUri, + allowLoopbackHttp: true, + CreateRedirectDisabledHandler(), + timeout); + + /// + /// Creates the explicit process-local feed seam used by the Campaign LA + /// isolated operator fixture. HTTPS stays HTTPS-only. HTTP is admitted + /// only for a loopback manifest, and never by the pinned production + /// constructor. Credential-bearing or mutable URI suffixes are rejected. + /// + public static ReleaseManifestClient CreateLocalUpdateFeedOverride( + Uri manifestUri, + TimeSpan? timeout = null) + { + ArgumentNullException.ThrowIfNull(manifestUri); + if (!string.IsNullOrEmpty(manifestUri.UserInfo) + || !string.IsNullOrEmpty(manifestUri.Query) + || !string.IsNullOrEmpty(manifestUri.Fragment)) + { + throw new LauncherUpdateException( + "A process-local manifest URI cannot contain user information, " + + "a query, or a fragment."); + } + + bool allowLoopbackHttp = string.Equals( + manifestUri.Scheme, + Uri.UriSchemeHttp, + StringComparison.Ordinal) + && manifestUri.IsLoopback; + if (!string.Equals( + manifestUri.Scheme, + Uri.UriSchemeHttps, + StringComparison.Ordinal) + && !allowLoopbackHttp) + { + throw new LauncherUpdateException( + "A process-local manifest URI must use HTTPS " + + "(loopback HTTP is fixture-only)."); + } + + return new ReleaseManifestClient( + manifestUri, + allowLoopbackHttp, + CreateRedirectDisabledHandler(), + timeout); + } + + internal static ReleaseManifestClient CreateForTransportTest( + Uri manifestUri, + bool allowLoopbackHttp, + HttpMessageHandler handler) => new( + manifestUri, + allowLoopbackHttp, + handler, + TimeSpan.FromSeconds(15)); + + public async Task FetchAsync( + CancellationToken cancellationToken = default) + { + try + { + Uri current = _manifestUri; + var visited = new HashSet(StringComparer.Ordinal); + for (int redirectCount = 0;;) + { + RequireTransport(current, "manifest redirect", _allowLoopbackHttp); + if (!visited.Add(current.AbsoluteUri)) + { + throw new LauncherUpdateException( + "The release manifest redirect chain contains a loop."); + } + + using var request = new HttpRequestMessage(HttpMethod.Get, current); + using HttpResponseMessage response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken) + .ConfigureAwait(false); + if (IsRedirect(response.StatusCode)) + { + if (redirectCount >= MaximumRedirects) + { + throw new LauncherUpdateException( + $"The release manifest exceeded {MaximumRedirects} redirects."); + } + + Uri? location = response.Headers.Location; + if (location is null) + { + throw new LauncherUpdateException( + "The release manifest redirect has no Location header."); + } + + Uri next = location.IsAbsoluteUri + ? location + : new Uri(current, location); + RequireTransport(next, "manifest redirect", _allowLoopbackHttp); + current = next; + redirectCount++; + continue; + } + + response.EnsureSuccessStatusCode(); + return await ReadAndParseAsync(response, cancellationToken) + .ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + throw; + } + catch (LauncherUpdateException) + { + throw; + } + catch (Exception ex) when (ex is HttpRequestException + or IOException + or JsonException + or NotSupportedException) + { + throw new LauncherUpdateException( + $"The release manifest could not be loaded: {ex.Message}", + ex); + } + } + + internal static ReleaseManifest Parse( + ReadOnlySpan utf8, + bool allowLoopbackHttpArtifacts = false) + { + try + { + using JsonDocument document = JsonDocument.Parse( + utf8.ToArray(), + new JsonDocumentOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + MaxDepth = 16, + }); + RejectDuplicateProperties(document.RootElement, "$" ); + ManifestDocument? value = document.RootElement.Deserialize( + SerializerOptions); + return Validate(value, allowLoopbackHttpArtifacts); + } + catch (LauncherUpdateException) + { + throw; + } + catch (Exception ex) when (ex is JsonException + or FormatException + or InvalidOperationException) + { + throw new LauncherUpdateException( + $"The release manifest is invalid: {ex.Message}", + ex); + } + } + + public void Dispose() => _httpClient.Dispose(); + + internal static void RequireTransport( + Uri uri, + string description, + bool allowLoopbackHttp) + { + if (!uri.IsAbsoluteUri + || (uri.Scheme != Uri.UriSchemeHttps + && !(allowLoopbackHttp + && uri.Scheme == Uri.UriSchemeHttp + && uri.IsLoopback))) + { + throw new LauncherUpdateException( + $"The {description} URI must use HTTPS" + + (allowLoopbackHttp ? " (or fixture-only loopback HTTP)." : ".")); + } + } + + internal static void RequireSecureOrLoopback(Uri uri, string description) => + RequireTransport(uri, description, allowLoopbackHttp: true); + + private async Task ReadAndParseAsync( + HttpResponseMessage response, + CancellationToken cancellationToken) + { + if (response.Content.Headers.ContentLength is long contentLength + && contentLength > MaximumManifestBytes) + { + throw new LauncherUpdateException( + $"The release manifest is larger than {MaximumManifestBytes} bytes."); + } + + await using Stream input = await response.Content + .ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + using var output = new MemoryStream(); + byte[] buffer = new byte[16 * 1024]; + while (true) + { + int read = await input.ReadAsync(buffer, cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + break; + } + + if (output.Length + read > MaximumManifestBytes) + { + throw new LauncherUpdateException( + $"The release manifest is larger than {MaximumManifestBytes} bytes."); + } + + output.Write(buffer, 0, read); + } + + return Parse(output.ToArray(), _allowLoopbackHttp); + } + + private static ReleaseManifest Validate( + ManifestDocument? document, + bool allowLoopbackHttpArtifacts) + { + if (document is null) + { + throw new LauncherUpdateException("The release manifest is empty."); + } + + if (document.SchemaVersion != ReleaseManifest.CurrentSchemaVersion) + { + throw new LauncherUpdateException( + $"Release manifest schema version {document.SchemaVersion} is not supported."); + } + + LauncherVersion version = LauncherVersion.Parse( + document.Version + ?? throw new LauncherUpdateException("The release version is missing.")); + LauncherVersion minimum = LauncherVersion.Parse( + document.MinimumLauncherVersion + ?? throw new LauncherUpdateException( + "The minimum launcher version is missing.")); + if (minimum > version) + { + throw new LauncherUpdateException( + "The minimum launcher version cannot exceed the release version."); + } + + IReadOnlyDictionary clients = ValidateArtifacts( + document.Clients, + "clients", + allowLoopbackHttpArtifacts); + IReadOnlyDictionary launchers = ValidateArtifacts( + document.Launchers, + "launchers", + allowLoopbackHttpArtifacts); + return new ReleaseManifest(version, minimum, clients, launchers); + } + + private static IReadOnlyDictionary ValidateArtifacts( + Dictionary? artifacts, + string field, + bool allowLoopbackHttpArtifacts) + { + if (artifacts is null || artifacts.Count == 0) + { + throw new LauncherUpdateException($"Manifest field '{field}' must not be empty."); + } + + var result = new Dictionary(StringComparer.Ordinal); + foreach ((string rid, ArtifactDocument value) in artifacts) + { + if (!LauncherRuntimeIdentity.IsValidRid(rid)) + { + throw new LauncherUpdateException( + $"Manifest field '{field}' contains invalid RID '{rid}'."); + } + + if (value is null) + { + throw new LauncherUpdateException( + $"Manifest payload '{field}.{rid}' is null."); + } + + if (!Uri.TryCreate(value.Url, UriKind.Absolute, out Uri? uri)) + { + throw new LauncherUpdateException( + $"Manifest payload '{field}.{rid}' has an invalid URL."); + } + + RequireTransport( + uri, + $"{field}.{rid} artifact", + allowLoopbackHttpArtifacts); + if (!IsSha256(value.Sha256)) + { + throw new LauncherUpdateException( + $"Manifest payload '{field}.{rid}' has an invalid SHA-256 digest."); + } + + if (value.Size <= 0 || value.Size > MaximumArtifactBytes) + { + throw new LauncherUpdateException( + $"Manifest payload '{field}.{rid}' has an invalid size."); + } + + result.Add( + rid, + new ReleaseArtifact(uri, value.Sha256!.ToLowerInvariant(), value.Size)); + } + + return result; + } + + internal static bool IsSha256(string? value) => + value is { Length: 64 } && value.All(Uri.IsHexDigit); + + private static bool IsRedirect(HttpStatusCode statusCode) => statusCode is + HttpStatusCode.MovedPermanently + or HttpStatusCode.Found + or HttpStatusCode.SeeOther + or HttpStatusCode.TemporaryRedirect + or HttpStatusCode.PermanentRedirect; + + private static HttpMessageHandler CreateRedirectDisabledHandler() => + new HttpClientHandler + { + AllowAutoRedirect = false, + UseCookies = false, + AutomaticDecompression = DecompressionMethods.None, + }; + + private static void RejectDuplicateProperties(JsonElement element, string path) + { + if (element.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (JsonProperty property in element.EnumerateObject()) + { + if (!names.Add(property.Name)) + { + throw new LauncherUpdateException( + $"Duplicate JSON property '{path}.{property.Name}' is not allowed."); + } + + RejectDuplicateProperties(property.Value, $"{path}.{property.Name}"); + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + int index = 0; + foreach (JsonElement item in element.EnumerateArray()) + { + RejectDuplicateProperties(item, $"{path}[{index++}]"); + } + } + } + + private sealed class ManifestDocument + { + public int SchemaVersion { get; init; } + + public string? Version { get; init; } + + public string? MinimumLauncherVersion { get; init; } + + public Dictionary? Clients { get; init; } + + public Dictionary? Launchers { get; init; } + } + + private sealed class ArtifactDocument + { + public string? Url { get; init; } + + public string? Sha256 { get; init; } + + public long Size { get; init; } + } +} diff --git a/src/AcDream.Launcher.Core/Updates/SafeZipExtractor.cs b/src/AcDream.Launcher.Core/Updates/SafeZipExtractor.cs new file mode 100644 index 00000000..e7a39b5f --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/SafeZipExtractor.cs @@ -0,0 +1,469 @@ +using System.Buffers; +using System.IO.Compression; +using System.Security.Cryptography; + +namespace AcDream.Launcher.Core.Updates; + +public sealed record SafeZipExtractionLimits( + int MaximumEntries = 20_000, + long MaximumEntryBytes = 2L * 1024 * 1024 * 1024, + long MaximumTotalBytes = 8L * 1024 * 1024 * 1024, + double MaximumCompressionRatio = 200, + int MaximumRelativePathLength = 512); + +public sealed record ExtractedFileRecord( + string Path, + string Sha256, + long Size, + int UnixMode); + +/// +/// Portable ZIP extractor for release assets. The complete central-directory +/// shape is validated before the first output path is created. +/// +public sealed class SafeZipExtractor +{ + private const int BufferSize = 128 * 1024; + private const int UnixTypeMask = 0xF000; + private const int UnixRegularFile = 0x8000; + private const int UnixDirectory = 0x4000; + private const int UnixPermissionMask = 0x1FF; + private readonly SafeZipExtractionLimits _limits; + + public SafeZipExtractor(SafeZipExtractionLimits? limits = null) + { + _limits = limits ?? new SafeZipExtractionLimits(); + if (_limits.MaximumEntries <= 0 + || _limits.MaximumEntryBytes <= 0 + || _limits.MaximumTotalBytes <= 0 + || _limits.MaximumCompressionRatio <= 0 + || _limits.MaximumRelativePathLength <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(limits), + "ZIP extraction limits must all be positive."); + } + } + + public async Task> ExtractAsync( + string archivePath, + string destinationDirectory, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(archivePath); + ArgumentException.ThrowIfNullOrWhiteSpace(destinationDirectory); + string archive = Path.GetFullPath(archivePath); + string destination = Path.GetFullPath(destinationDirectory); + + if (Directory.Exists(destination) + && Directory.EnumerateFileSystemEntries(destination).Any()) + { + throw new LauncherUpdateException( + "The ZIP extraction destination must be empty."); + } + + try + { + await using var stream = new FileStream( + archive, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + BufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); + using var zip = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: false); + IReadOnlyList entries = ValidateArchive(zip); + + Directory.CreateDirectory(destination); + RejectReparsePoint(destination, "extraction root"); + foreach (string directory in entries + .SelectMany(entry => ParentPaths(entry.RelativePath)) + .Concat(entries.Where(entry => entry.IsDirectory) + .Select(entry => entry.RelativePath)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(path => path.Count(character => character == '/')) + .ThenBy(path => path, StringComparer.Ordinal)) + { + cancellationToken.ThrowIfCancellationRequested(); + string directoryPath = ResolveContained(destination, directory); + Directory.CreateDirectory(directoryPath); + RejectReparsePoint(directoryPath, $"directory '{directory}'"); + } + + var files = new List(); + long actualTotal = 0; + foreach (ValidatedEntry entry in entries.Where(entry => !entry.IsDirectory)) + { + cancellationToken.ThrowIfCancellationRequested(); + string outputPath = ResolveContained(destination, entry.RelativePath); + EnsureParentsAreDirectories(destination, entry.RelativePath); + await using Stream input = entry.Entry.Open(); + await using var output = new FileStream( + outputPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + BufferSize, + FileOptions.Asynchronous + | FileOptions.SequentialScan + | FileOptions.WriteThrough); + using IncrementalHash hash = IncrementalHash.CreateHash( + HashAlgorithmName.SHA256); + byte[] buffer = ArrayPool.Shared.Rent(BufferSize); + long actualEntry = 0; + try + { + while (true) + { + int read = await input.ReadAsync( + buffer.AsMemory(0, BufferSize), + cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + break; + } + + actualEntry = checked(actualEntry + read); + actualTotal = checked(actualTotal + read); + if (actualEntry > entry.Entry.Length + || actualEntry > _limits.MaximumEntryBytes + || actualTotal > _limits.MaximumTotalBytes) + { + throw new LauncherUpdateException( + $"ZIP entry '{entry.RelativePath}' exceeded its declared limits."); + } + + hash.AppendData(buffer, 0, read); + await output.WriteAsync( + buffer.AsMemory(0, read), + cancellationToken) + .ConfigureAwait(false); + } + + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + output.Flush(flushToDisk: true); + } + finally + { + ArrayPool.Shared.Return(buffer, clearArray: true); + } + + if (actualEntry != entry.Entry.Length) + { + throw new LauncherUpdateException( + $"ZIP entry '{entry.RelativePath}' length changed while extracting."); + } + + int unixMode = entry.UnixMode & UnixPermissionMask; + if (OperatingSystem.IsLinux() && unixMode != 0) + { + File.SetUnixFileMode(outputPath, (UnixFileMode)unixMode); + } + + files.Add(new ExtractedFileRecord( + entry.RelativePath, + Convert.ToHexStringLower(hash.GetHashAndReset()), + actualEntry, + unixMode)); + } + + files.Sort((left, right) => string.Compare( + left.Path, + right.Path, + StringComparison.Ordinal)); + return files; + } + catch (OperationCanceledException) + { + TryDeleteDirectory(destination); + throw; + } + catch (LauncherUpdateException) + { + TryDeleteDirectory(destination); + throw; + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or InvalidDataException + or NotSupportedException + or CryptographicException) + { + TryDeleteDirectory(destination); + throw new LauncherUpdateException( + $"The release ZIP could not be extracted safely: {ex.Message}", + ex); + } + } + + private IReadOnlyList ValidateArchive(ZipArchive zip) + { + if (zip.Entries.Count == 0 || zip.Entries.Count > _limits.MaximumEntries) + { + throw new LauncherUpdateException( + $"ZIP entry count {zip.Entries.Count} is outside the allowed range."); + } + + var result = new List(zip.Entries.Count); + var explicitEntries = new HashSet(StringComparer.OrdinalIgnoreCase); + var nodes = new Dictionary(StringComparer.OrdinalIgnoreCase); + long totalLength = 0; + long totalCompressed = 0; + foreach (ZipArchiveEntry entry in zip.Entries) + { + string relative = NormalizeEntryPath(entry.FullName); + if (!explicitEntries.Add(relative)) + { + throw new LauncherUpdateException( + $"ZIP contains a duplicate/case-colliding entry '{relative}'."); + } + + int unixAttributes = entry.ExternalAttributes >> 16; + int unixType = unixAttributes & UnixTypeMask; + bool trailingDirectory = entry.FullName.EndsWith("/", StringComparison.Ordinal) + || entry.FullName.EndsWith("\\", StringComparison.Ordinal); + bool isDirectory = trailingDirectory || unixType == UnixDirectory; + if ((entry.ExternalAttributes & (int)FileAttributes.ReparsePoint) != 0 + || unixType is not (0 or UnixRegularFile or UnixDirectory) + || (unixType == UnixDirectory && !trailingDirectory) + || (isDirectory && (entry.Length != 0 || entry.CompressedLength != 0))) + { + throw new LauncherUpdateException( + $"ZIP entry '{relative}' is a symlink, reparse point, or unsupported type."); + } + + AddPathNodes(nodes, relative, isDirectory); + if (!isDirectory) + { + if (entry.Length < 0 + || entry.CompressedLength < 0 + || entry.Length > _limits.MaximumEntryBytes) + { + throw new LauncherUpdateException( + $"ZIP entry '{relative}' exceeds the per-file limit."); + } + + totalLength = checked(totalLength + entry.Length); + totalCompressed = checked(totalCompressed + entry.CompressedLength); + if (totalLength > _limits.MaximumTotalBytes + || IsRatioExceeded(entry.Length, entry.CompressedLength)) + { + throw new LauncherUpdateException( + $"ZIP entry '{relative}' exceeds extraction size/ratio limits."); + } + } + + result.Add(new ValidatedEntry(entry, relative, isDirectory, unixAttributes)); + } + + if (totalLength > 0 + && (totalCompressed == 0 || IsRatioExceeded(totalLength, totalCompressed))) + { + throw new LauncherUpdateException( + "ZIP aggregate compression ratio exceeds the allowed limit."); + } + + return result; + } + + private string NormalizeEntryPath(string name) + { + if (string.IsNullOrEmpty(name) + || name.IndexOf('\0') >= 0 + || name.Contains(':', StringComparison.Ordinal)) + { + throw new LauncherUpdateException("ZIP contains an empty, NUL, or ADS path."); + } + + string normalized = name.Replace('\\', '/'); + bool directory = normalized.EndsWith("/", StringComparison.Ordinal); + normalized = normalized.TrimEnd('/'); + if (normalized.Length == 0 + || normalized.Length > _limits.MaximumRelativePathLength + || normalized.StartsWith("/", StringComparison.Ordinal) + || Path.IsPathRooted(normalized)) + { + throw new LauncherUpdateException($"ZIP path '{name}' is rooted or too long."); + } + + string[] segments = normalized.Split('/'); + foreach (string segment in segments) + { + if (segment.Length == 0 + || segment is "." or ".." + || segment.EndsWith(' ') + || segment.EndsWith('.') + || segment.Any(character => + char.IsControl(character) + || character is '<' or '>' or '"' or '|' or '?' or '*') + || PortablePathRules.IsWindowsDeviceName(segment)) + { + throw new LauncherUpdateException( + $"ZIP path '{name}' contains an unsafe segment."); + } + } + + return string.Join('/', segments) + (directory ? "/" : string.Empty); + } + + private static void AddPathNodes( + Dictionary nodes, + string relative, + bool isDirectory) + { + string path = relative.TrimEnd('/'); + string[] segments = path.Split('/'); + string current = string.Empty; + for (int index = 0; index < segments.Length; index++) + { + current = current.Length == 0 + ? segments[index] + : current + "/" + segments[index]; + bool nodeIsDirectory = index < segments.Length - 1 || isDirectory; + if (nodes.TryGetValue(current, out PathNode? existing)) + { + if (!string.Equals(existing.Spelling, current, StringComparison.Ordinal) + || (!existing.IsDirectory || !nodeIsDirectory)) + { + throw new LauncherUpdateException( + $"ZIP path '{relative}' collides with '{existing.Spelling}'."); + } + + continue; + } + + nodes.Add(current, new PathNode(current, nodeIsDirectory)); + } + } + + private bool IsRatioExceeded(long expanded, long compressed) => + expanded > 0 + && (compressed <= 0 || expanded / (double)compressed > _limits.MaximumCompressionRatio); + + private static IEnumerable ParentPaths(string relative) + { + string path = relative.TrimEnd('/'); + int slash = path.IndexOf('/'); + while (slash >= 0) + { + yield return path[..slash]; + slash = path.IndexOf('/', slash + 1); + } + } + + private static string ResolveContained(string root, string relative) + { + string path = Path.GetFullPath( + Path.Combine(root, relative.TrimEnd('/').Replace('/', Path.DirectorySeparatorChar))); + string prefix = Path.EndsInDirectorySeparator(root) + ? root + : root + Path.DirectorySeparatorChar; + if (!path.StartsWith( + prefix, + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal)) + { + throw new LauncherUpdateException( + $"ZIP path '{relative}' escaped the extraction directory."); + } + + return path; + } + + private static void EnsureParentsAreDirectories(string root, string relative) + { + foreach (string parent in ParentPaths(relative)) + { + string path = ResolveContained(root, parent); + if (!Directory.Exists(path)) + { + throw new LauncherUpdateException( + $"ZIP parent '{parent}' is not a directory."); + } + + RejectReparsePoint(path, $"directory '{parent}'"); + } + } + + private static void RejectReparsePoint(string path, string description) + { + if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + { + throw new LauncherUpdateException( + $"The {description} is a reparse point."); + } + } + + internal static void TryDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) + { + DeleteDirectoryWithoutFollowingReparsePoints(path); + } + } + catch + { + // The exact random staging name is reclaimed under the update lease. + } + } + + private static void DeleteDirectoryWithoutFollowingReparsePoints(string directory) + { + FileAttributes rootAttributes = File.GetAttributes(directory); + if ((rootAttributes & FileAttributes.ReparsePoint) != 0) + { + DeleteReparsePoint(directory); + return; + } + + foreach (string entry in Directory.EnumerateFileSystemEntries( + directory, + "*", + SearchOption.TopDirectoryOnly)) + { + FileAttributes attributes = File.GetAttributes(entry); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + DeleteReparsePoint(entry); + } + else if ((attributes & FileAttributes.Directory) != 0) + { + DeleteDirectoryWithoutFollowingReparsePoints(entry); + } + else + { + File.Delete(entry); + } + } + + Directory.Delete(directory, recursive: false); + } + + private static void DeleteReparsePoint(string path) + { + try + { + File.Delete(path); + } + catch (UnauthorizedAccessException) + { + Directory.Delete(path, recursive: false); + } + catch (IOException) + { + Directory.Delete(path, recursive: false); + } + } + + private sealed record PathNode(string Spelling, bool IsDirectory); + + private sealed record ValidatedEntry( + ZipArchiveEntry Entry, + string RelativePath, + bool IsDirectory, + int UnixMode); +} diff --git a/src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs b/src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs new file mode 100644 index 00000000..fbb21197 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs @@ -0,0 +1,177 @@ +namespace AcDream.Launcher.Core.Updates; + +/// +/// One portable OS-handle barrier shared by supervised sessions and held +/// exclusively by update/rollback/recovery transactions. File contents are +/// never authoritative. +/// +public sealed class UpdateSessionBarrier +{ + public const string LockFileName = ".update-session.lock"; + + private readonly string _lockPath; + + public UpdateSessionBarrier(string dataDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory); + _lockPath = Path.Combine( + Path.GetFullPath(dataDirectory), + "app", + LockFileName); + } + + public string LockPath => _lockPath; + + public SessionLease AcquireSession() + { + FileStream stream = Open(FileShare.ReadWrite, "A client update is in progress."); + return new SessionLease(stream); + } + + /// + /// Non-blocking shared-lease probe used only by launcher startup after an + /// exclusive probe observed contention. Success proves that no updater + /// owns the exclusive lease at that instant; permission and path failures + /// remain hard errors. + /// + public bool TryAcquireSession(out SessionLease? lease) + { + Directory.CreateDirectory( + Path.GetDirectoryName(_lockPath) + ?? throw new InvalidOperationException( + "The update/session lock path has no parent directory.")); + try + { + lease = new SessionLease( + new FileStream( + _lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.ReadWrite, + bufferSize: 1, + FileOptions.None)); + return true; + } + catch (IOException) + { + lease = null; + return false; + } + catch (UnauthorizedAccessException ex) + { + throw new LauncherUpdateException( + $"The update/session lease could not be opened: {ex.Message}", + ex); + } + } + + public ExclusiveLease AcquireExclusive() + { + FileStream stream = Open( + FileShare.None, + "A launcher session or another update transaction is running. " + + "Stop every launcher session before updating."); + return new ExclusiveLease(this, stream); + } + + /// + /// Non-blocking startup probe. Contention is an expected "not now" + /// result; permission and path failures remain hard errors. + /// + public bool TryAcquireExclusive(out ExclusiveLease? lease) + { + Directory.CreateDirectory( + Path.GetDirectoryName(_lockPath) + ?? throw new InvalidOperationException( + "The update/session lock path has no parent directory.")); + try + { + lease = new ExclusiveLease( + this, + new FileStream( + _lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + FileOptions.None)); + return true; + } + catch (IOException) + { + lease = null; + return false; + } + catch (UnauthorizedAccessException ex) + { + throw new LauncherUpdateException( + $"The update/session lease could not be opened: {ex.Message}", + ex); + } + } + + internal void RequireOwned(ExclusiveLease lease) + { + ArgumentNullException.ThrowIfNull(lease); + if (!lease.IsHeldBy(this)) + { + throw new LauncherUpdateException( + "The cleanup operation does not hold this update barrier's exclusive lease."); + } + } + + private FileStream Open(FileShare share, string refusal) + { + Directory.CreateDirectory( + Path.GetDirectoryName(_lockPath) + ?? throw new InvalidOperationException( + "The update/session lock path has no parent directory.")); + try + { + return new FileStream( + _lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + share, + bufferSize: 1, + FileOptions.None); + } + catch (IOException ex) + { + throw new LauncherUpdateException(refusal, ex); + } + catch (UnauthorizedAccessException ex) + { + throw new LauncherUpdateException( + $"The update/session lease could not be opened: {ex.Message}", + ex); + } + } + + public sealed class SessionLease : IDisposable + { + private FileStream? _stream; + + internal SessionLease(FileStream stream) => _stream = stream; + + public void Dispose() => Interlocked.Exchange(ref _stream, null)?.Dispose(); + } + + public sealed class ExclusiveLease : IDisposable + { + private readonly UpdateSessionBarrier _owner; + private FileStream? _stream; + + internal ExclusiveLease(UpdateSessionBarrier owner, FileStream stream) + { + _owner = owner; + _stream = stream; + } + + internal bool IsHeldBy(UpdateSessionBarrier owner) => + ReferenceEquals(_owner, owner) + && Volatile.Read(ref _stream) is not null; + + public void Dispose() => Interlocked.Exchange(ref _stream, null)?.Dispose(); + } +} diff --git a/src/AcDream.Launcher.Core/Updates/VerifiedArtifactDownloader.cs b/src/AcDream.Launcher.Core/Updates/VerifiedArtifactDownloader.cs new file mode 100644 index 00000000..6589d102 --- /dev/null +++ b/src/AcDream.Launcher.Core/Updates/VerifiedArtifactDownloader.cs @@ -0,0 +1,277 @@ +using System.Buffers; +using System.Net; +using System.Security.Cryptography; + +namespace AcDream.Launcher.Core.Updates; + +public sealed record ArtifactDownloadProgress(long BytesReceived, long TotalBytes) +{ + public double Percent => TotalBytes <= 0 + ? 0 + : Math.Clamp(BytesReceived * 100d / TotalBytes, 0, 100); +} + +public sealed record VerifiedArtifactDownload( + string FilePath, + long Size, + string Sha256); + +/// +/// Streams a bounded release asset directly to a caller-owned staging path, +/// computing SHA-256 during the write. A partial/cancelled/wrong artifact is +/// deleted before the call returns. +/// +public sealed class VerifiedArtifactDownloader +{ + private const int BufferSize = 128 * 1024; + private readonly HttpClient _httpClient; + + public VerifiedArtifactDownloader(HttpClient httpClient) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + public async Task DownloadAsync( + ReleaseArtifact artifact, + string destinationPath, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(artifact); + ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath); + ReleaseManifestClient.RequireSecureOrLoopback(artifact.Url, "artifact"); + if (artifact.Size <= 0 + || artifact.Size > ReleaseManifestClient.MaximumArtifactBytes + || !ReleaseManifestClient.IsSha256(artifact.Sha256)) + { + throw new LauncherUpdateException("The requested artifact metadata is invalid."); + } + + string fullPath = Path.GetFullPath(destinationPath); + Directory.CreateDirectory( + Path.GetDirectoryName(fullPath) + ?? throw new InvalidOperationException( + "The artifact staging path has no parent directory.")); + + bool ownsDestination = false; + try + { + using HttpResponseMessage response = await SendWithValidatedRedirectsAsync( + artifact.Url, + cancellationToken) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + if (response.Content.Headers.ContentLength is long contentLength + && contentLength != artifact.Size) + { + throw new LauncherUpdateException( + $"Artifact size header mismatch: expected {artifact.Size}, " + + $"received {contentLength}."); + } + + if (response.Content.Headers.ContentEncoding.Count != 0) + { + throw new LauncherUpdateException( + "Release artifact content encoding is not allowed."); + } + + await using Stream input = await response.Content + .ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + await using var output = new FileStream( + fullPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + BufferSize, + FileOptions.Asynchronous + | FileOptions.SequentialScan + | FileOptions.WriteThrough); + ownsDestination = true; + using IncrementalHash hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + byte[] buffer = ArrayPool.Shared.Rent(BufferSize); + long received = 0; + try + { + progress?.Report(new ArtifactDownloadProgress(0, artifact.Size)); + while (true) + { + int read = await input.ReadAsync( + buffer.AsMemory(0, BufferSize), + cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + break; + } + + received = checked(received + read); + if (received > artifact.Size) + { + throw new LauncherUpdateException( + $"Artifact exceeded its declared size of {artifact.Size} bytes."); + } + + hash.AppendData(buffer, 0, read); + await output.WriteAsync( + buffer.AsMemory(0, read), + cancellationToken) + .ConfigureAwait(false); + progress?.Report(new ArtifactDownloadProgress(received, artifact.Size)); + } + + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + output.Flush(flushToDisk: true); + } + finally + { + ArrayPool.Shared.Return(buffer, clearArray: true); + } + + if (received != artifact.Size) + { + throw new LauncherUpdateException( + $"Artifact ended at {received} bytes; expected {artifact.Size}."); + } + + string actualSha256 = Convert.ToHexStringLower(hash.GetHashAndReset()); + if (!string.Equals( + actualSha256, + artifact.Sha256, + StringComparison.OrdinalIgnoreCase)) + { + throw new LauncherUpdateException( + "Artifact SHA-256 does not match the release manifest."); + } + + return new VerifiedArtifactDownload(fullPath, received, actualSha256); + } + catch (OperationCanceledException) + { + if (ownsDestination) + { + TryDelete(fullPath); + } + + throw; + } + catch (LauncherUpdateException) + { + if (ownsDestination) + { + TryDelete(fullPath); + } + + throw; + } + catch (Exception ex) when (ex is HttpRequestException + or IOException + or UnauthorizedAccessException + or CryptographicException) + { + if (ownsDestination) + { + TryDelete(fullPath); + } + + throw new LauncherUpdateException( + $"The release artifact could not be downloaded: {ex.Message}", + ex); + } + } + + private async Task SendWithValidatedRedirectsAsync( + Uri initialUri, + CancellationToken cancellationToken) + { + bool allowLoopbackHttp = initialUri.Scheme == Uri.UriSchemeHttp + && initialUri.IsLoopback; + Uri current = initialUri; + var visited = new HashSet(StringComparer.Ordinal); + for (int redirectCount = 0;;) + { + ReleaseManifestClient.RequireTransport( + current, + "artifact redirect", + allowLoopbackHttp); + if (!visited.Add(current.AbsoluteUri)) + { + throw new LauncherUpdateException( + "The release artifact redirect chain contains a loop."); + } + + using var request = new HttpRequestMessage(HttpMethod.Get, current); + HttpResponseMessage response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken) + .ConfigureAwait(false); + Uri effectiveUri = response.RequestMessage?.RequestUri ?? current; + if (!Uri.Equals(effectiveUri, current)) + { + response.Dispose(); + throw new LauncherUpdateException( + "The artifact HTTP transport followed an automatic redirect; " + + "every redirect must be validated before it is requested."); + } + + if (!IsRedirect(response.StatusCode)) + { + return response; + } + + try + { + if (redirectCount >= ReleaseManifestClient.MaximumRedirects) + { + throw new LauncherUpdateException( + $"The release artifact exceeded " + + $"{ReleaseManifestClient.MaximumRedirects} redirects."); + } + + Uri? location = response.Headers.Location; + if (location is null) + { + throw new LauncherUpdateException( + "The release artifact redirect has no Location header."); + } + + Uri next = location.IsAbsoluteUri + ? location + : new Uri(current, location); + ReleaseManifestClient.RequireTransport( + next, + "artifact redirect", + allowLoopbackHttp); + current = next; + redirectCount++; + } + finally + { + response.Dispose(); + } + } + } + + private static bool IsRedirect(HttpStatusCode statusCode) => statusCode is + HttpStatusCode.MovedPermanently + or HttpStatusCode.Found + or HttpStatusCode.SeeOther + or HttpStatusCode.TemporaryRedirect + or HttpStatusCode.PermanentRedirect; + + internal static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch + { + // The exact random staging name is reclaimed by startup recovery. + } + } +} diff --git a/src/AcDream.Launcher/AcDream.Launcher.csproj b/src/AcDream.Launcher/AcDream.Launcher.csproj new file mode 100644 index 00000000..5ae49a22 --- /dev/null +++ b/src/AcDream.Launcher/AcDream.Launcher.csproj @@ -0,0 +1,133 @@ + + + WinExe + acdream-launcher + AcDream.Launcher + net10.0 + enable + enable + latest + true + true + true + true + true + + true + <_BakeExecutableName Condition="$([MSBuild]::IsOSPlatform('Windows'))">acdream-bake.exe + <_BakeExecutableName Condition="'$(_BakeExecutableName)' == ''">acdream-bake + + + + + + <_BakeToolSource Include="$(MSBuildProjectDirectory)\..\AcDream.Bake\**\*.cs" + Exclude="$(MSBuildProjectDirectory)\..\AcDream.Bake\bin\**\*.cs;$(MSBuildProjectDirectory)\..\AcDream.Bake\obj\**\*.cs" /> + <_BakeToolSource Include="$(MSBuildProjectDirectory)\..\AcDream.Bake\AcDream.Bake.csproj" /> + <_BakeToolSource Include="$(MSBuildProjectDirectory)\..\AcDream.Content\**\*.cs" + Exclude="$(MSBuildProjectDirectory)\..\AcDream.Content\bin\**\*.cs;$(MSBuildProjectDirectory)\..\AcDream.Content\obj\**\*.cs" /> + <_BakeToolSource Include="$(MSBuildProjectDirectory)\..\AcDream.Content\AcDream.Content.csproj" /> + <_BakeToolSource Include="$(MSBuildProjectDirectory)\..\AcDream.Platform\**\*.cs" + Exclude="$(MSBuildProjectDirectory)\..\AcDream.Platform\bin\**\*.cs;$(MSBuildProjectDirectory)\..\AcDream.Platform\obj\**\*.cs" /> + <_BakeToolSource Include="$(MSBuildProjectDirectory)\..\AcDream.Platform\AcDream.Platform.csproj" /> + <_BakeToolSource Include="$(MSBuildProjectDirectory)\..\AcDream.Core\**\*.cs" + Exclude="$(MSBuildProjectDirectory)\..\AcDream.Core\bin\**\*.cs;$(MSBuildProjectDirectory)\..\AcDream.Core\obj\**\*.cs" /> + <_BakeToolSource Include="$(MSBuildProjectDirectory)\..\AcDream.Core\AcDream.Core.csproj" /> + <_BakeToolSource Include="$(MSBuildProjectDirectory)\..\AcDream.Plugin.Abstractions\**\*.cs" + Exclude="$(MSBuildProjectDirectory)\..\AcDream.Plugin.Abstractions\bin\**\*.cs;$(MSBuildProjectDirectory)\..\AcDream.Plugin.Abstractions\obj\**\*.cs" /> + <_BakeToolSource Include="$(MSBuildProjectDirectory)\..\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj" /> + + + + + + + + + + + + + + + + + + + + + <_BakeBuildRid Condition="'$(RuntimeIdentifier)' != ''">$(RuntimeIdentifier) + <_BakeBuildRid Condition="'$(_BakeBuildRid)' == ''">$(NETCoreSdkPortableRuntimeIdentifier) + <_BakeBuildStagingDirectory>$(MSBuildProjectDirectory)\$(BaseIntermediateOutputPath)bake-codeploy\$(Configuration)\$(_BakeBuildRid)\ + <_BakeBuildOutputDirectory Condition="$([System.IO.Path]::IsPathRooted('$(OutputPath)'))">$(OutputPath) + <_BakeBuildOutputDirectory Condition="'$(_BakeBuildOutputDirectory)' == ''">$(MSBuildProjectDirectory)\$(OutputPath) + + + + + + + + + + + + + <_BakePublishDirectory Condition="$([System.IO.Path]::IsPathRooted('$(PublishDir)'))">$(PublishDir) + <_BakePublishDirectory Condition="'$(_BakePublishDirectory)' == ''">$(MSBuildProjectDirectory)\$(PublishDir) + + + + diff --git a/src/AcDream.Launcher/App.axaml b/src/AcDream.Launcher/App.axaml new file mode 100644 index 00000000..3f6dbf49 --- /dev/null +++ b/src/AcDream.Launcher/App.axaml @@ -0,0 +1,8 @@ + + + + + diff --git a/src/AcDream.Launcher/App.axaml.cs b/src/AcDream.Launcher/App.axaml.cs new file mode 100644 index 00000000..b348cf07 --- /dev/null +++ b/src/AcDream.Launcher/App.axaml.cs @@ -0,0 +1,127 @@ +using System.Reflection; +using AcDream.Launcher.Core.Installation; +using AcDream.Launcher.Core.Launching; +using AcDream.Launcher.Core.Orchestration; +using AcDream.Launcher.Core.Profiles; +using AcDream.Launcher.Core.Updates; +using AcDream.Launcher.ViewModels; +using AcDream.Platform; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; + +namespace AcDream.Launcher; + +public sealed partial class App : Application +{ + private readonly LauncherStartupOptions? _startupOptions; + private LauncherOrchestrator? _orchestrator; + private LauncherWindowViewModel? _viewModel; + private LauncherUpdateComposition? _updateComposition; + + public App() + { + } + + internal App(LauncherStartupOptions startupOptions) + { + _startupOptions = startupOptions + ?? throw new ArgumentNullException(nameof(startupOptions)); + } + + internal LauncherStartupOptions StartupOptions => _startupOptions + ?? throw new InvalidOperationException( + "Launcher startup options were not supplied by the composition root."); + + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + LauncherStartupOptions startupOptions = StartupOptions; + ApplicationPathSet paths = startupOptions.Paths; + LauncherProfileStore profiles = LauncherProfileStore.ForApplicationPaths(paths); + string rid = LauncherRuntimeIdentity.DetectRid(); + string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty; + var installer = new LauncherInstaller( + paths, + 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, + rid, + GetLauncherVersion(), + AppContext.BaseDirectory, + () => _orchestrator?.GetSnapshot().Sessions.Any(session => session.IsActive) + == true, + updateManifestUri: startupOptions.UpdateManifestUri); + _updateComposition = updates; + + _orchestrator = new LauncherOrchestrator( + profiles, + paths, + updates.Executables, + verification.Record, + installationStatus: verification.Status, + updateSessionBarrier: updates.Versions.Barrier); + _viewModel = new LauncherWindowViewModel( + _orchestrator, + new AvaloniaUiDispatcher(), + installer, + updates.Updater); + _viewModel.Initialize(); + + desktop.MainWindow = new MainWindow + { + DataContext = _viewModel, + }; + desktop.Exit += OnDesktopExit; + } + + base.OnFrameworkInitializationCompleted(); + } + + private void OnDesktopExit(object? sender, ControlledApplicationLifetimeExitEventArgs e) + { + _viewModel?.Dispose(); + _orchestrator?.Dispose(); + _updateComposition?.Dispose(); + _viewModel = null; + _orchestrator = null; + _updateComposition = null; + } + + private static LauncherVersion GetLauncherVersion() + { + string? informationalVersion = typeof(App).Assembly + .GetCustomAttribute()? + .InformationalVersion; + if (!LauncherVersion.TryParse(informationalVersion, out LauncherVersion? version)) + { + throw new InvalidOperationException( + $"Launcher informational version '{informationalVersion}' is not SemVer 2.0."); + } + + return version; + } +} diff --git a/src/AcDream.Launcher/LauncherStartupOptions.cs b/src/AcDream.Launcher/LauncherStartupOptions.cs new file mode 100644 index 00000000..b3364cbc --- /dev/null +++ b/src/AcDream.Launcher/LauncherStartupOptions.cs @@ -0,0 +1,255 @@ +using AcDream.Launcher.Core.Updates; +using AcDream.Platform; + +namespace AcDream.Launcher; + +internal enum LauncherStartupMode +{ + Desktop, + VerifyPublish, + SelfUpdateHelper, + SelfUpdateConfirmation, +} + +/// +/// Immutable, process-local launcher inputs. Parsing happens before any +/// launcher owner is constructed so every owner receives the same exact path +/// set and the test-feed URI can reach only the updater composition. +/// +internal sealed class LauncherStartupOptions +{ + private readonly IReadOnlyList _publicArguments; + + private LauncherStartupOptions( + LauncherStartupMode mode, + ApplicationPathSet paths, + Uri updateManifestUri, + IReadOnlyList publicArguments) + { + Mode = mode; + Paths = paths; + UpdateManifestUri = updateManifestUri; + _publicArguments = Array.AsReadOnly(publicArguments.ToArray()); + } + + internal LauncherStartupMode Mode { get; } + + internal ApplicationPathSet Paths { get; } + + internal Uri UpdateManifestUri { get; } + + /// + /// The validated public option suffix. LA10 passes this suffix through its + /// helper and confirmation processes so an isolated self-update cannot + /// fall back to canonical user roots or the production feed. + /// + internal IReadOnlyList PublicArguments => _publicArguments; + + internal static LauncherStartupOptions Parse( + IReadOnlyList arguments, + Func? resolveDefaultPaths = null) + { + ArgumentNullException.ThrowIfNull(arguments); + resolveDefaultPaths ??= () => ApplicationPathSet.Resolve(); + + (LauncherStartupMode mode, int publicStart) = ReadMode(arguments); + string[] publicArguments = arguments.Skip(publicStart).ToArray(); + + if (publicArguments.Contains("--verify-publish", StringComparer.Ordinal)) + { + if (mode != LauncherStartupMode.Desktop + || publicArguments.Length != 1 + || !string.Equals( + publicArguments[0], + "--verify-publish", + StringComparison.Ordinal)) + { + throw new LauncherStartupOptionsException( + "--verify-publish must be the only launcher argument."); + } + + return new LauncherStartupOptions( + LauncherStartupMode.VerifyPublish, + // The publish probe returns before this value is observed. A + // non-resolving sentinel keeps the probe display- and + // user-profile-free even under a deliberately broken runtime. + new ApplicationPathSet(string.Empty, string.Empty, string.Empty, null), + ReleaseManifestClient.ProductionManifestUri, + publicArguments); + } + + string? configDirectory = null; + string? dataDirectory = null; + string? cacheDirectory = null; + Uri? updateManifestUri = null; + + for (int index = 0; index < publicArguments.Length; index += 2) + { + string name = publicArguments[index]; + if (index + 1 >= publicArguments.Length + || publicArguments[index + 1].StartsWith("--", StringComparison.Ordinal)) + { + throw new LauncherStartupOptionsException( + $"Launcher option '{name}' requires a value."); + } + + string value = publicArguments[index + 1]; + if (string.IsNullOrWhiteSpace(value)) + { + throw new LauncherStartupOptionsException( + $"Launcher option '{name}' requires a non-empty value."); + } + + switch (name) + { + case "--config-dir": + SetDirectoryOnce(ref configDirectory, value, name); + break; + case "--data-dir": + SetDirectoryOnce(ref dataDirectory, value, name); + break; + case "--cache-dir": + SetDirectoryOnce(ref cacheDirectory, value, name); + break; + case "--update-manifest-uri": + if (updateManifestUri is not null) + { + throw new LauncherStartupOptionsException( + "Launcher options cannot be repeated."); + } + + if (!Uri.TryCreate(value, UriKind.Absolute, out Uri? parsed)) + { + throw new LauncherStartupOptionsException( + "--update-manifest-uri must be an absolute URI."); + } + + if (parsed.Scheme != Uri.UriSchemeHttps + && !(parsed.Scheme == Uri.UriSchemeHttp && parsed.IsLoopback)) + { + throw new LauncherStartupOptionsException( + "The update manifest URI must use HTTPS " + + "(loopback HTTP is test-only)."); + } + + if (!string.IsNullOrEmpty(parsed.UserInfo)) + { + throw new LauncherStartupOptionsException( + "The update manifest URI cannot contain user information."); + } + + if (!string.IsNullOrEmpty(parsed.Query) + || !string.IsNullOrEmpty(parsed.Fragment)) + { + throw new LauncherStartupOptionsException( + "The update manifest URI cannot contain a query or fragment."); + } + + updateManifestUri = parsed; + break; + default: + throw new LauncherStartupOptionsException( + $"Unknown launcher option '{name}'."); + } + } + + int suppliedRoots = new[] { configDirectory, dataDirectory, cacheDirectory } + .Count(path => path is not null); + if (suppliedRoots is > 0 and < 3) + { + throw new LauncherStartupOptionsException( + "--config-dir, --data-dir, and --cache-dir must be supplied together."); + } + + ApplicationPathSet paths = suppliedRoots == 3 + ? new ApplicationPathSet( + configDirectory!, + dataDirectory!, + cacheDirectory!, + LegacyConfigDirectory: null) + : resolveDefaultPaths(); + return new LauncherStartupOptions( + mode, + paths, + updateManifestUri ?? ReleaseManifestClient.ProductionManifestUri, + publicArguments); + } + + private static (LauncherStartupMode Mode, int PublicStart) ReadMode( + IReadOnlyList arguments) + { + if (arguments.Count == 0) + { + return (LauncherStartupMode.Desktop, 0); + } + + if (string.Equals( + arguments[0], + LauncherSelfUpdateBootstrap.HelperArgument, + StringComparison.Ordinal)) + { + // Malformed internal invocations are rejected by the bootstrap + // with EX_USAGE. Do not reinterpret their operands as public + // options while resolving the manager they need to report that. + return ( + LauncherStartupMode.SelfUpdateHelper, + arguments.Count >= 4 ? 4 : arguments.Count); + } + + if (string.Equals( + arguments[0], + LauncherSelfUpdateBootstrap.ConfirmArgument, + StringComparison.Ordinal)) + { + return ( + LauncherStartupMode.SelfUpdateConfirmation, + arguments.Count >= 2 ? 2 : arguments.Count); + } + + return (LauncherStartupMode.Desktop, 0); + } + + private static void SetDirectoryOnce( + ref string? destination, + string value, + string option) + { + if (destination is not null) + { + throw new LauncherStartupOptionsException( + "Launcher options cannot be repeated."); + } + + if (!Path.IsPathFullyQualified(value)) + { + throw new LauncherStartupOptionsException( + $"Launcher option '{option}' must be an absolute path."); + } + + try + { + destination = Path.TrimEndingDirectorySeparator(Path.GetFullPath(value)); + } + catch (Exception ex) when (ex is ArgumentException + or IOException + or NotSupportedException) + { + throw new LauncherStartupOptionsException( + $"Launcher option '{option}' is not a valid absolute path.", + ex); + } + } +} + +internal sealed class LauncherStartupOptionsException : Exception +{ + internal LauncherStartupOptionsException(string message) + : base(message) + { + } + + internal LauncherStartupOptionsException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/AcDream.Launcher/LauncherUpdateComposition.cs b/src/AcDream.Launcher/LauncherUpdateComposition.cs new file mode 100644 index 00000000..22af717f --- /dev/null +++ b/src/AcDream.Launcher/LauncherUpdateComposition.cs @@ -0,0 +1,147 @@ +using System.Net; +using System.Security; +using System.Text.Json; +using AcDream.Launcher.Core.Orchestration; +using AcDream.Launcher.Core.Updates; +using AcDream.Launcher.ViewModels; +using AcDream.Platform; + +namespace AcDream.Launcher; + +/// +/// Testable startup transaction for versioned-client/update services. Storage +/// failures produce a fail-closed executable resolver and an unavailable UI +/// projection; they do not abort profile/installer window construction. +/// +internal sealed class LauncherUpdateComposition : IDisposable +{ + private readonly HttpClient? _artifactClient; + private readonly ReleaseManifestClient? _manifestClient; + + private LauncherUpdateComposition( + ClientVersionStore versions, + LauncherExecutableSet executables, + ILauncherUpdater updater, + Uri updateManifestUri, + HttpClient? artifactClient, + ReleaseManifestClient? manifestClient) + { + Versions = versions; + Executables = executables; + Updater = updater; + UpdateManifestUri = updateManifestUri; + _artifactClient = artifactClient; + _manifestClient = manifestClient; + } + + public ClientVersionStore Versions { get; } + + public LauncherExecutableSet Executables { get; } + + public ILauncherUpdater Updater { get; } + + internal Uri UpdateManifestUri { get; } + + public static LauncherUpdateComposition Create( + ApplicationPathSet paths, + string rid, + LauncherVersion launcherVersion, + string launcherTargetDirectory, + Func hasRunningSessions, + Func? initialize = null, + Uri? updateManifestUri = null) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(launcherVersion); + ArgumentNullException.ThrowIfNull(hasRunningSessions); + Uri manifestUri = updateManifestUri + ?? ReleaseManifestClient.ProductionManifestUri; + var versions = new ClientVersionStore(paths); + HttpClient? artifactClient = null; + ReleaseManifestClient? manifestClient = null; + try + { + _ = initialize is null + ? versions.LoadAndRecoverAsync(rid).GetAwaiter().GetResult() + : initialize(versions, rid); + artifactClient = new HttpClient( + new HttpClientHandler + { + AllowAutoRedirect = false, + UseCookies = false, + AutomaticDecompression = DecompressionMethods.None, + }, + disposeHandler: true) + { + Timeout = TimeSpan.FromSeconds(15), + }; + artifactClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1"); + manifestClient = CreateManifestClient(manifestUri); + var selfUpdates = new LauncherSelfUpdateManager(paths, artifactClient); + var updater = new LauncherUpdater( + manifestClient, + artifactClient, + versions, + selfUpdates, + launcherVersion, + rid, + launcherTargetDirectory, + hasRunningSessions); + return new LauncherUpdateComposition( + versions, + LauncherExecutableSet.FromCurrentVersionStore(versions), + updater, + manifestUri, + artifactClient, + manifestClient); + } + catch (Exception ex) when (IsStorageFailure(ex)) + { + manifestClient?.Dispose(); + artifactClient?.Dispose(); + string status = "Versioned client update storage is unavailable: " + + (string.IsNullOrWhiteSpace(ex.Message) + ? "the storage operation failed." + : ex.Message); + var resolution = new ClientVersionResolution( + ClientVersionState.Invalid, + status, + null, + null, + null, + null); + return new LauncherUpdateComposition( + versions, + LauncherExecutableSet.Unavailable(status), + new UnavailableLauncherUpdater(status, resolution), + manifestUri, + artifactClient: null, + manifestClient: null); + } + } + + public void Dispose() + { + _manifestClient?.Dispose(); + _artifactClient?.Dispose(); + } + + private static ReleaseManifestClient CreateManifestClient(Uri manifestUri) + { + ArgumentNullException.ThrowIfNull(manifestUri); + return manifestUri == ReleaseManifestClient.ProductionManifestUri + ? new ReleaseManifestClient(TimeSpan.FromSeconds(15)) + : ReleaseManifestClient.CreateLocalUpdateFeedOverride( + manifestUri, + TimeSpan.FromSeconds(15)); + } + + private static bool IsStorageFailure(Exception exception) => exception is + IOException + or UnauthorizedAccessException + or SecurityException + or JsonException + or FormatException + or NotSupportedException + or LauncherUpdateException; +} diff --git a/src/AcDream.Launcher/MainWindow.axaml b/src/AcDream.Launcher/MainWindow.axaml new file mode 100644 index 00000000..2f0a8515 --- /dev/null +++ b/src/AcDream.Launcher/MainWindow.axaml @@ -0,0 +1,507 @@ + + + + + + + + + + + + + + + + + + public sealed class ChatPanelFocusTests { - private sealed class NullBus : AcDream.UI.Abstractions.ICommandBus + private sealed class NullBus : AcDream.Runtime.Chat.ICommandBus { public void Publish(T command) where T : notnull { } } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs index 8e3a95be..77aac3f7 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs @@ -47,8 +47,8 @@ public sealed class ChatPanelInputTests var entries = log.Snapshot(); Assert.Equal(2, entries.Length); Assert.All(entries, entry => Assert.Equal(ChatKind.System, entry.Kind)); - Assert.Equal(AcDream.UI.Abstractions.Panels.Chat.RetailCommandHelpTable.HelpPrefixNote, entries[0].Text); - Assert.Equal(AcDream.UI.Abstractions.Panels.Chat.RetailCommandHelpTable.AvailableHelpListing, entries[1].Text); + Assert.Equal(RetailCommandHelpTable.HelpPrefixNote, entries[0].Text); + Assert.Equal(RetailCommandHelpTable.AvailableHelpListing, entries[1].Text); } [Theory] diff --git a/tests/Fixtures/campaign-la/LauncherCoreSessionConfigFixture.cs b/tests/Fixtures/campaign-la/LauncherCoreSessionConfigFixture.cs new file mode 100644 index 00000000..cc841868 --- /dev/null +++ b/tests/Fixtures/campaign-la/LauncherCoreSessionConfigFixture.cs @@ -0,0 +1,120 @@ +using AcDream.Launcher.Core.Launching; +using AcDream.Launcher.Core.Profiles; +using AcDream.Platform; + +namespace AcDream.Tests.Fixtures.CampaignLa; + +/// +/// Produces one real Launcher.Core session document that is compiled into +/// both host test suites. Keeping composition in one linked fixture makes the +/// LA1/LA3 anti-drift gate prove that App and Headless accept the identical +/// composer output rather than two hand-maintained lookalikes. +/// +internal static class LauncherCoreSessionConfigFixture +{ + internal const string Password = "must-not-be-serialized"; + + internal static string Compose() + { + var server = new ServerProfile + { + Name = "Composer Server", + Host = "composer.example", + Port = 9010, + }; + var account = new AccountProfile + { + Account = "composer-account", + Password = Password, + }; + var character = new CharacterProfile + { + Name = "Composer Character", + Id = "0x50000001", + LaunchMode = LaunchMode.Headless, + Plugins = ["ComposerPlugin"], + LoginCommands = ["/composer command"], + }; + var install = new LauncherInstallRecord( + "composer-dats", + "composer-dats/acdream.pak"); + var paths = new ApplicationPathSet( + Path.Combine(Path.GetTempPath(), "composer-config"), + Path.Combine(Path.GetTempPath(), "composer-data"), + Path.Combine(Path.GetTempPath(), "composer-cache"), + LegacyConfigDirectory: null); + + ComposedSessionConfig composed = SessionConfigComposer.Compose( + server, + account, + character, + install, + paths, + "composer-contract", + loginCommandDelayMs: 625); + + return SessionConfigComposer.Serialize(composed.Document); + } + + internal static string ComposeEmptyPlugins() + { + (ServerProfile server, AccountProfile account, + LauncherInstallRecord install, ApplicationPathSet paths) = Inputs(); + var character = new CharacterProfile + { + Name = "Composer Character", + Id = "0x50000001", + LaunchMode = LaunchMode.Headless, + Plugins = [], + LoginCommands = [], + }; + + ComposedSessionConfig composed = SessionConfigComposer.Compose( + server, + account, + character, + install, + paths, + "composer-empty-plugins"); + return SessionConfigComposer.Serialize(composed.Document); + } + + internal static string ComposeProbe() + { + (ServerProfile server, AccountProfile account, + LauncherInstallRecord install, ApplicationPathSet paths) = Inputs(); + ComposedSessionConfig composed = SessionConfigComposer.ComposeProbe( + server, + account, + install, + paths, + "composer-probe"); + return SessionConfigComposer.Serialize(composed.Document); + } + + private static ( + ServerProfile Server, + AccountProfile Account, + LauncherInstallRecord Install, + ApplicationPathSet Paths) Inputs() => + ( + new ServerProfile + { + Name = "Composer Server", + Host = "composer.example", + Port = 9010, + }, + new AccountProfile + { + Account = "composer-account", + Password = Password, + }, + new LauncherInstallRecord( + "composer-dats", + "composer-dats/acdream.pak"), + new ApplicationPathSet( + Path.Combine(Path.GetTempPath(), "composer-config"), + Path.Combine(Path.GetTempPath(), "composer-data"), + Path.Combine(Path.GetTempPath(), "composer-cache"), + LegacyConfigDirectory: null)); +} diff --git a/tests/Fixtures/campaign-la/session-config-shared-fixture.json b/tests/Fixtures/campaign-la/session-config-shared-fixture.json new file mode 100644 index 00000000..24ec55a1 --- /dev/null +++ b/tests/Fixtures/campaign-la/session-config-shared-fixture.json @@ -0,0 +1,26 @@ +{ + "version": 1, + "process": { + "content": { + "datDirectory": "shared-fixture-dats", + "preparedAssetPath": "shared-fixture-dats/acdream.pak" + } + }, + "sessions": [ + { + "id": "shared-fixture", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "sharedaccount", + "character": { "name": "SharedToon" }, + "policy": { "id": "idle" }, + "credential": { + "provider": "standardInput", + "reference": "session" + }, + "plugins": ["ExamplePlugin", "AnotherPlugin"], + "loginCommands": ["/tell someone, hi", "/vt start"], + "loginCommandDelayMs": 750, + "statusFile": "shared-fixture-status.jsonl" + } + ] +} diff --git a/tools/CampaignLaProcessCorrelation.ps1 b/tools/CampaignLaProcessCorrelation.ps1 new file mode 100644 index 00000000..25864a2d --- /dev/null +++ b/tools/CampaignLaProcessCorrelation.ps1 @@ -0,0 +1,232 @@ +Set-StrictMode -Version Latest + +function Get-CampaignLaSha256([string]$Text) { + $bytes = [Text.Encoding]::UTF8.GetBytes($Text) + return [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData($bytes)).ToLowerInvariant() +} + +function Get-CampaignLaCommandLineFingerprint( + [string]$ExecutablePath, + [string]$ConfigArgument, + [string]$SessionConfigPath) { + if (-not [IO.Path]::IsPathFullyQualified($ExecutablePath) -or + -not [IO.Path]::IsPathFullyQualified($SessionConfigPath) -or + $ConfigArgument -cnotin @('--config', '--session-config')) { + throw 'Cannot fingerprint an incomplete launcher-child command line.' + } + $executable = [IO.Path]::GetFullPath($ExecutablePath) + $config = [IO.Path]::GetFullPath($SessionConfigPath) + # Only the executable and the recognized config argument are retained in + # this projection. Launcher credentials use stdin; unrelated argv is + # deliberately excluded so an accidental secret can never enter evidence. + return Get-CampaignLaSha256( + "campaign-la-child-command-v1`n$executable`n$ConfigArgument`n$config") +} + +function Get-CampaignLaLinuxProcessIdentity( + [string]$ProcessDirectory, + [string]$BootId) { + $stat = [IO.File]::ReadAllText((Join-Path $ProcessDirectory 'stat')) + $commandEnd = $stat.LastIndexOf(')') + if ($commandEnd -lt 2 -or $commandEnd + 2 -ge $stat.Length) { + throw 'Linux process stat record is malformed.' + } + # The tail begins at field 3 (state); field 22 (starttime) is index 19. + $tail = @($stat.Substring($commandEnd + 2).Split( + ' ', + [StringSplitOptions]::RemoveEmptyEntries)) + if ($tail.Count -le 19) { throw 'Linux process stat record has no starttime.' } + $startTicks = [uint64]::Parse( + $tail[19], + [Globalization.NumberStyles]::None, + [Globalization.CultureInfo]::InvariantCulture) + return "linux-proc-start-v1:$BootId`:$startTicks" +} + +function Get-CampaignLaProcessInstanceIdentity { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateRange(1, 2147483647)][int]$ProcessId) + + if ($IsWindows) { + $candidate = Get-CimInstance Win32_Process ` + -Filter "ProcessId=$ProcessId" -ErrorAction Stop + if ($null -eq $candidate) { return $null } + if ($null -eq $candidate.CreationDate) { + throw "Windows process $ProcessId has no creation time." + } + return "windows-creation-v1:$($candidate.CreationDate.ToUniversalTime().Ticks)" + } + if ($IsLinux) { + $directory = "/proc/$ProcessId" + if (-not [IO.Directory]::Exists($directory)) { return $null } + try { + $bootId = [IO.File]::ReadAllText( + '/proc/sys/kernel/random/boot_id').Trim().ToLowerInvariant() + if ($bootId -notmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') { + throw 'Linux boot id is malformed.' + } + return Get-CampaignLaLinuxProcessIdentity $directory $bootId + } + catch [IO.FileNotFoundException] { return $null } + catch [IO.DirectoryNotFoundException] { return $null } + catch [IO.IOException] { + if (-not [IO.Directory]::Exists($directory)) { return $null } + throw + } + } + throw 'Campaign LA process identity supports Windows and Linux only.' +} + +function Get-CampaignLaSessionProcessCorrelations { + [CmdletBinding()] + param() + + $correlations = [Collections.Generic.List[object]]::new() + if ($IsWindows) { + $pattern = '(?i)(?:^|\s)(--config|--session-config)\s+(?:"([^"]+)"|(\S+))' + foreach ($candidate in @(Get-CimInstance Win32_Process -ErrorAction Stop)) { + $commandLine = [string]$candidate.CommandLine + $executablePath = [string]$candidate.ExecutablePath + if ([string]::IsNullOrWhiteSpace($commandLine) -or + -not [IO.Path]::IsPathFullyQualified($executablePath) -or + $null -eq $candidate.CreationDate) { + continue + } + $identity = "windows-creation-v1:$($candidate.CreationDate.ToUniversalTime().Ticks)" + foreach ($match in [Text.RegularExpressions.Regex]::Matches( + $commandLine, + $pattern)) { + $argument = $match.Groups[1].Value.ToLowerInvariant() + $value = if ($match.Groups[2].Success) { + $match.Groups[2].Value + } else { $match.Groups[3].Value } + if ([IO.Path]::IsPathFullyQualified($value)) { + $configPath = [IO.Path]::GetFullPath($value) + $correlations.Add([pscustomobject]@{ + ProcessId = [int]$candidate.ProcessId + ProcessInstanceIdentity = $identity + SessionConfigPath = $configPath + CommandLineFingerprintSha256 = + Get-CampaignLaCommandLineFingerprint ` + $executablePath $argument $configPath + }) + } + } + } + } + elseif ($IsLinux) { + $bootId = [IO.File]::ReadAllText('/proc/sys/kernel/random/boot_id').Trim().ToLowerInvariant() + if ($bootId -notmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') { + throw 'Linux boot id is malformed.' + } + foreach ($directory in [IO.Directory]::EnumerateDirectories('/proc')) { + $leaf = [IO.Path]::GetFileName($directory) + $processId = 0 + if (-not [int]::TryParse( + $leaf, + [Globalization.NumberStyles]::None, + [Globalization.CultureInfo]::InvariantCulture, + [ref]$processId)) { + continue + } + try { + $identityBefore = Get-CampaignLaLinuxProcessIdentity $directory $bootId + $bytes = [IO.File]::ReadAllBytes((Join-Path $directory 'cmdline')) + if ($bytes.Length -eq 0) { continue } + $arguments = @([Text.Encoding]::UTF8.GetString($bytes).Split( + [char]0, + [StringSplitOptions]::RemoveEmptyEntries)) + $identityAfter = Get-CampaignLaLinuxProcessIdentity $directory $bootId + if ($identityBefore -cne $identityAfter -or $arguments.Count -eq 0 -or + -not [IO.Path]::IsPathFullyQualified($arguments[0])) { + continue + } + for ($index = 0; $index + 1 -lt $arguments.Count; $index++) { + if ($arguments[$index] -cin @('--config', '--session-config') -and + [IO.Path]::IsPathFullyQualified($arguments[$index + 1])) { + $configPath = [IO.Path]::GetFullPath($arguments[$index + 1]) + $correlations.Add([pscustomobject]@{ + ProcessId = $processId + ProcessInstanceIdentity = $identityBefore + SessionConfigPath = $configPath + CommandLineFingerprintSha256 = + Get-CampaignLaCommandLineFingerprint ` + $arguments[0] $arguments[$index] $configPath + }) + } + } + } + catch [IO.IOException] { + # A process may exit between /proc enumeration and either read. + } + catch [UnauthorizedAccessException] { + # Other-user processes cannot be the owner-readable gate child. + } + } + } + else { + throw 'Campaign LA process correlation supports Windows and Linux only.' + } + + return @($correlations) +} + +function Get-CampaignLaCorrelatedProcessIds { + [CmdletBinding()] + param([Parameter(Mandatory = $true)][string]$SessionConfigPath) + + if (-not [IO.Path]::IsPathFullyQualified($SessionConfigPath)) { + throw 'Session-config correlation requires an absolute path.' + } + $SessionConfigPath = [IO.Path]::GetFullPath($SessionConfigPath) + $comparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase + } else { [StringComparison]::Ordinal } + $processIds = [Collections.Generic.HashSet[int]]::new() + foreach ($candidate in @(Get-CampaignLaSessionProcessCorrelations)) { + if ([string]::Equals( + $candidate.SessionConfigPath, + $SessionConfigPath, + $comparison)) { + $null = $processIds.Add([int]$candidate.ProcessId) + } + } + + return @($processIds | Sort-Object) +} + +function Test-CampaignLaCapturedProcessState { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][int]$ProcessId, + [Parameter(Mandatory = $true)][string]$ProcessInstanceIdentity, + [Parameter(Mandatory = $true)][string]$SessionConfigPath, + [AllowNull()][string]$CurrentProcessInstanceIdentity, + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()][object[]]$Correlations) + + $comparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase + } else { [StringComparison]::Ordinal } + $sameInstanceAlive = -not [string]::IsNullOrEmpty($CurrentProcessInstanceIdentity) -and + $CurrentProcessInstanceIdentity -ceq $ProcessInstanceIdentity + $exactConfigPathAlive = $false + $pidReused = -not [string]::IsNullOrEmpty($CurrentProcessInstanceIdentity) -and + $CurrentProcessInstanceIdentity -cne $ProcessInstanceIdentity + foreach ($candidate in $Correlations) { + if ([string]::Equals( + [string]$candidate.SessionConfigPath, + $SessionConfigPath, + $comparison)) { + $exactConfigPathAlive = $true + } + } + return [pscustomobject]@{ + SameInstanceAlive = $sameInstanceAlive + ExactConfigPathAlive = $exactConfigPathAlive + PidReused = $pidReused + } +} diff --git a/tools/capture-campaign-la-session-process.ps1 b/tools/capture-campaign-la-session-process.ps1 new file mode 100644 index 00000000..4b0fd74a --- /dev/null +++ b/tools/capture-campaign-la-session-process.ps1 @@ -0,0 +1,117 @@ +<# +.SYNOPSIS + Captures one launcher child PID by its unique isolated session-config path. + +.DESCRIPTION + Writes a sanitized gate-only sidecar. It never reads the session-config + contents and records no command line, account, character, or credential. +#> +[CmdletBinding(DefaultParameterSetName = 'Path')] +param( + [Parameter(Mandatory = $true, ParameterSetName = 'Path')] + [string]$SessionConfigPath, + [Parameter(Mandatory = $true, ParameterSetName = 'Directory')] + [string]$SessionsDirectory, + [Parameter(ParameterSetName = 'Directory')] + [DateTimeOffset]$CreatedAfterUtc = [DateTimeOffset]::MinValue, + [Parameter(Mandatory = $true)][string]$ReportPath, + [ValidateRange(1, 60)][int]$WaitSeconds = 10 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'Campaign LA PID capture requires PowerShell 7 or newer.' +} +. (Join-Path $PSScriptRoot 'CampaignLaProcessCorrelation.ps1') + +if ($PSCmdlet.ParameterSetName -eq 'Path') { + if (-not [IO.Path]::IsPathFullyQualified($SessionConfigPath)) { + throw '-SessionConfigPath must be absolute.' + } + $SessionConfigPath = [IO.Path]::GetFullPath($SessionConfigPath) + if (-not (Test-Path -LiteralPath $SessionConfigPath -PathType Leaf)) { + throw "Session config does not exist: $SessionConfigPath" + } +} +else { + if (-not [IO.Path]::IsPathFullyQualified($SessionsDirectory)) { + throw '-SessionsDirectory must be absolute.' + } + $SessionsDirectory = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($SessionsDirectory)) + if (-not (Test-Path -LiteralPath $SessionsDirectory -PathType Container)) { + throw "Sessions directory does not exist: $SessionsDirectory" + } +} +if (-not [IO.Path]::IsPathFullyQualified($ReportPath)) { + throw '-ReportPath must be absolute.' +} +$ReportPath = [IO.Path]::GetFullPath($ReportPath) +if (Test-Path -LiteralPath $ReportPath) { + throw '-ReportPath must be fresh.' +} + +$deadline = [DateTime]::UtcNow.AddSeconds($WaitSeconds) +do { + if ($PSCmdlet.ParameterSetName -eq 'Path') { + $correlations = @(Get-CampaignLaSessionProcessCorrelations | + Where-Object { + $comparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase + } else { [StringComparison]::Ordinal } + [string]::Equals( + $_.SessionConfigPath, + $SessionConfigPath, + $comparison) + }) + } + else { + $comparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase + } else { [StringComparison]::Ordinal } + $prefix = $SessionsDirectory + [IO.Path]::DirectorySeparatorChar + $correlations = @(Get-CampaignLaSessionProcessCorrelations | + Where-Object { + $_.SessionConfigPath.StartsWith($prefix, $comparison) -and + [IO.Path]::GetFileName($_.SessionConfigPath) -ceq 'session.json' -and + (Test-Path -LiteralPath $_.SessionConfigPath -PathType Leaf) -and + (Get-Item -LiteralPath $_.SessionConfigPath).LastWriteTimeUtc -ge + $CreatedAfterUtc.UtcDateTime + }) + } + if ($correlations.Count -eq 1) { break } + if ($correlations.Count -gt 1) { + throw "More than one process uses the isolated session config." + } + Start-Sleep -Milliseconds 100 +} while ([DateTime]::UtcNow -lt $deadline) +if ($correlations.Count -ne 1) { + throw 'No live process uses the isolated session config.' +} +$SessionConfigPath = [IO.Path]::GetFullPath($correlations[0].SessionConfigPath) +$processIdentity = [string]$correlations[0].ProcessInstanceIdentity +$commandFingerprint = [string]$correlations[0].CommandLineFingerprintSha256 +if ($processIdentity -notmatch '^(windows-creation-v1:[0-9]{15,19}|linux-proc-start-v1:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:[0-9]+)$' -or + $commandFingerprint -notmatch '^[0-9a-f]{64}$') { + throw 'The correlated process instance evidence is malformed.' +} + +$directory = Split-Path -Parent $ReportPath +if (-not [string]::IsNullOrEmpty($directory)) { + $null = New-Item -ItemType Directory -Force -Path $directory +} +$report = [ordered]@{ + schemaVersion = 2 + kind = 'campaign-la-session-process-capture' + processId = [int]$correlations[0].ProcessId + processInstanceIdentity = $processIdentity + sessionId = [IO.Path]::GetFileName( + [IO.Path]::GetDirectoryName($SessionConfigPath)) + sessionConfigPath = $SessionConfigPath + commandLineFingerprintSha256 = $commandFingerprint + capturedUtc = [DateTime]::UtcNow.ToString('O') +} +$report | ConvertTo-Json -Depth 3 | + Set-Content -LiteralPath $ReportPath -Encoding utf8NoBOM +Write-Host "Campaign LA process capture: $ReportPath" diff --git a/tools/new-campaign-la-update-fixture.ps1 b/tools/new-campaign-la-update-fixture.ps1 new file mode 100644 index 00000000..d6609f47 --- /dev/null +++ b/tools/new-campaign-la-update-fixture.ps1 @@ -0,0 +1,500 @@ +<# +.SYNOPSIS + Creates deterministic isolated Campaign LA A/B update feeds. + +.DESCRIPTION + Packages caller-supplied published client and launcher roots for win-x64 + and linux-x64, adds a deterministic release marker, calculates the exact + LA10 SHA-256/size manifest fields, and emits a loopback-only static server + plus a local A/B selector. It never downloads, connects, edits a payload + source, or writes outside -OutputDirectory. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$OutputDirectory, + [Parameter(Mandatory = $true)][string]$ClientWinX64DirectoryA, + [Parameter(Mandatory = $true)][string]$LauncherWinX64DirectoryA, + [Parameter(Mandatory = $true)][string]$ClientLinuxX64DirectoryA, + [Parameter(Mandatory = $true)][string]$LauncherLinuxX64DirectoryA, + [Parameter(Mandatory = $true)][string]$ClientWinX64DirectoryB, + [Parameter(Mandatory = $true)][string]$LauncherWinX64DirectoryB, + [Parameter(Mandatory = $true)][string]$ClientLinuxX64DirectoryB, + [Parameter(Mandatory = $true)][string]$LauncherLinuxX64DirectoryB, + [string]$VersionA = '1.0.1-la11.a', + [string]$VersionB = '1.0.1-la11.b', + [string]$MinimumLauncherVersion = '1.0.0', + [int]$Port = 43119, + [switch]$DryRun +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'Campaign LA update fixture creation requires PowerShell 7 or newer.' +} +if (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) { + throw '-OutputDirectory must be absolute.' +} +$OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($OutputDirectory)) + +function Assert-NoReparseAncestry([string]$Path, [string]$Description) { + $cursor = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Path)) + while (-not (Test-Path -LiteralPath $cursor)) { + $parent = [IO.Path]::GetDirectoryName($cursor) + if ([string]::IsNullOrEmpty($parent) -or $parent -ceq $cursor) { break } + $cursor = $parent + } + while (-not [string]::IsNullOrEmpty($cursor)) { + $item = Get-Item -LiteralPath $cursor -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "$Description has a reparse point in its ancestry." + } + $parent = [IO.Directory]::GetParent($cursor) + if ($null -eq $parent) { break } + $cursor = $parent.FullName + } +} + +function Test-SameOrDescendant([string]$Path, [string]$Ancestor) { + $comparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase + } else { [StringComparison]::Ordinal } + if ([string]::Equals($Path, $Ancestor, $comparison)) { return $true } + $prefix = $Ancestor + [IO.Path]::DirectorySeparatorChar + return $Path.StartsWith($prefix, $comparison) +} + +Assert-NoReparseAncestry $OutputDirectory 'Output directory' +if ($Port -lt 1024 -or $Port -gt 65535) { throw '-Port must be 1024..65535.' } +$semver = '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$' +if ($VersionA -notmatch $semver -or $VersionB -notmatch $semver -or + $MinimumLauncherVersion -notmatch $semver -or $VersionA -ceq $VersionB) { + throw 'VersionA, VersionB, and MinimumLauncherVersion must be SemVer 2.0; A and B must differ.' +} +$parsedVersionA = [semver]$VersionA +$parsedVersionB = [semver]$VersionB +$parsedMinimumLauncherVersion = [semver]$MinimumLauncherVersion +if ($parsedVersionB.CompareTo($parsedVersionA) -le 0) { + throw 'VersionB must be newer than VersionA.' +} +if ($parsedMinimumLauncherVersion.CompareTo($parsedVersionA) -gt 0) { + throw 'MinimumLauncherVersion must not be newer than VersionA.' +} + +$sources = [ordered]@{ + 'A-client-win-x64' = $ClientWinX64DirectoryA + 'A-launcher-win-x64' = $LauncherWinX64DirectoryA + 'A-client-linux-x64' = $ClientLinuxX64DirectoryA + 'A-launcher-linux-x64' = $LauncherLinuxX64DirectoryA + 'B-client-win-x64' = $ClientWinX64DirectoryB + 'B-launcher-win-x64' = $LauncherWinX64DirectoryB + 'B-client-linux-x64' = $ClientLinuxX64DirectoryB + 'B-launcher-linux-x64' = $LauncherLinuxX64DirectoryB +} +foreach ($key in @($sources.Keys)) { + $source = [string]$sources[$key] + if (-not [IO.Path]::IsPathFullyQualified($source)) { + throw "Payload source '$key' must be absolute." + } + $source = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($source)) + $sources[$key] = $source + if (-not $DryRun -and -not (Test-Path -LiteralPath $source -PathType Container)) { + throw "Payload source '$key' does not exist: $source" + } + Assert-NoReparseAncestry $source "Payload source '$key'" + if ((Test-SameOrDescendant $OutputDirectory $source) -or + (Test-SameOrDescendant $source $OutputDirectory)) { + throw "Output directory and payload source '$key' must not overlap." + } +} + +function Require-PayloadFile([string]$Key, [string]$Name) { + if ($DryRun) { return } + if (-not (Test-Path -LiteralPath (Join-Path $sources[$Key] $Name) -PathType Leaf)) { + throw "Payload source '$Key' is missing root file '$Name'." + } +} +foreach ($release in @('A', 'B')) { + Require-PayloadFile "$release-client-win-x64" 'AcDream.App.exe' + Require-PayloadFile "$release-client-win-x64" 'acdream-headless.exe' + Require-PayloadFile "$release-launcher-win-x64" 'acdream-launcher.exe' + Require-PayloadFile "$release-launcher-win-x64" 'acdream-bake.exe' + Require-PayloadFile "$release-client-linux-x64" 'AcDream.App' + Require-PayloadFile "$release-client-linux-x64" 'acdream-headless' + Require-PayloadFile "$release-launcher-linux-x64" 'acdream-launcher' + Require-PayloadFile "$release-launcher-linux-x64" 'acdream-bake' +} + +if (Test-Path -LiteralPath $OutputDirectory) { + if (@(Get-ChildItem -LiteralPath $OutputDirectory -Force).Count -gt 0) { + throw '-OutputDirectory must not already contain files.' + } +} +else { $null = New-Item -ItemType Directory -Path $OutputDirectory } +Assert-NoReparseAncestry $OutputDirectory 'Output directory' + +if ($DryRun) { + $plan = [ordered]@{ + schemaVersion = 1 + kind = 'campaign-la-update-fixture-plan' + outputDirectory = $OutputDirectory + versions = @($VersionA, $VersionB) + minimumLauncherVersion = $MinimumLauncherVersion + port = $Port + sources = $sources + writesOutsideOutputDirectory = $false + externalNetwork = $false + } + $plan | ConvertTo-Json -Depth 5 | + Set-Content -LiteralPath (Join-Path $OutputDirectory 'dry-run.json') -Encoding utf8NoBOM + Write-Host "Campaign LA update fixture dry run: $OutputDirectory" + return +} + +Add-Type -AssemblyName System.IO.Compression +Add-Type -AssemblyName System.IO.Compression.FileSystem +$fixedTimestamp = [DateTimeOffset]::new(2000, 1, 1, 0, 0, 0, [TimeSpan]::Zero) + +function Get-LittleEndianUInt16([byte[]]$Bytes, [int]$Offset) { + return [int]$Bytes[$Offset] -bor ([int]$Bytes[$Offset + 1] -shl 8) +} + +function Get-LittleEndianUInt32([byte[]]$Bytes, [int]$Offset) { + return [uint32]([uint32]$Bytes[$Offset] -bor + ([uint32]$Bytes[$Offset + 1] -shl 8) -bor + ([uint32]$Bytes[$Offset + 2] -shl 16) -bor + ([uint32]$Bytes[$Offset + 3] -shl 24)) +} + +function Set-DeterministicZipHostPlatform([string]$Path) { + [byte[]]$bytes = [IO.File]::ReadAllBytes($Path) + $minimumEocdSize = 22 + if ($bytes.Length -lt $minimumEocdSize) { + throw "Generated ZIP is too short: $Path" + } + + $eocd = -1 + $minimumOffset = [Math]::Max(0, $bytes.Length - 65557) + for ($offset = $bytes.Length - $minimumEocdSize; $offset -ge $minimumOffset; $offset--) { + if ((Get-LittleEndianUInt32 $bytes $offset) -eq 0x06054b50) { + $commentLength = Get-LittleEndianUInt16 $bytes ($offset + 20) + if ($offset + $minimumEocdSize + $commentLength -eq $bytes.Length) { + $eocd = $offset + break + } + } + } + if ($eocd -lt 0) { throw "Generated ZIP has no valid end record: $Path" } + if ((Get-LittleEndianUInt16 $bytes ($eocd + 4)) -ne 0 -or + (Get-LittleEndianUInt16 $bytes ($eocd + 6)) -ne 0) { + throw "Generated ZIP unexpectedly spans multiple disks: $Path" + } + + $entriesOnDisk = Get-LittleEndianUInt16 $bytes ($eocd + 8) + $entryCount = Get-LittleEndianUInt16 $bytes ($eocd + 10) + if ($entriesOnDisk -ne $entryCount) { + throw "Generated ZIP central-directory count is inconsistent: $Path" + } + $centralSize = Get-LittleEndianUInt32 $bytes ($eocd + 12) + $centralOffset = Get-LittleEndianUInt32 $bytes ($eocd + 16) + if ([uint64]$centralOffset + [uint64]$centralSize -ne [uint64]$eocd) { + throw "Generated ZIP central-directory bounds are inconsistent: $Path" + } + + [uint64]$cursor = $centralOffset + for ($index = 0; $index -lt $entryCount; $index++) { + if ($cursor + 46 -gt $eocd -or + (Get-LittleEndianUInt32 $bytes ([int]$cursor)) -ne 0x02014b50) { + throw "Generated ZIP central-directory entry is invalid: $Path" + } + # ZipArchive stamps the creating host (FAT on Windows, Unix on Linux) + # in the upper byte of "version made by". Normalize to Unix so native + # extraction honors the explicit regular-file type and 0755/0644 mode + # bits already stored in ExternalAttributes. + $bytes[[int]$cursor + 5] = 3 + $nameLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 28) + $extraLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 30) + $commentLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 32) + $cursor += 46 + $nameLength + $extraLength + $commentLength + } + if ($cursor -ne $eocd) { + throw "Generated ZIP central-directory length is inconsistent: $Path" + } + [IO.File]::WriteAllBytes($Path, $bytes) +} + +function New-DeterministicZip( + [string]$SourceDirectory, + [string]$Destination, + [string]$ReleaseLabel, + [string]$PayloadKind, + [string]$Rid) { + $destinationDirectory = Split-Path -Parent $Destination + $null = New-Item -ItemType Directory -Force -Path $destinationDirectory + $stream = [IO.FileStream]::new( + $Destination, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::ReadWrite, + [IO.FileShare]::None) + try { + $archive = [IO.Compression.ZipArchive]::new( + $stream, + [IO.Compression.ZipArchiveMode]::Create, + $true, + [Text.Encoding]::UTF8) + try { + $allEntries = @(Get-ChildItem -LiteralPath $SourceDirectory -Force -Recurse) + foreach ($item in $allEntries) { + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Payload contains a reparse point: $($item.FullName)" + } + } + [string[]]$files = @($allEntries | + Where-Object { -not $_.PSIsContainer } | + ForEach-Object { + [IO.Path]::GetRelativePath( + $SourceDirectory, + $_.FullName).Replace('\', '/') + }) + [Array]::Sort($files, [StringComparer]::Ordinal) + $caseFolded = [Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase) + foreach ($relative in $files) { + if ($relative.StartsWith('../', [StringComparison]::Ordinal) -or + [IO.Path]::IsPathRooted($relative) -or + -not $caseFolded.Add($relative)) { + throw "Payload path escaped its root: $relative" + } + $file = Get-Item -LiteralPath ( + Join-Path $SourceDirectory $relative.Replace('/', [IO.Path]::DirectorySeparatorChar)) + $entry = $archive.CreateEntry($relative, [IO.Compression.CompressionLevel]::Optimal) + $entry.LastWriteTime = $fixedTimestamp + $executable = $relative -ceq 'AcDream.App' -or + $relative -ceq 'acdream-headless' -or + $relative -ceq 'acdream-launcher' -or + $relative -ceq 'acdream-bake' -or + $relative.EndsWith('.sh', [StringComparison]::Ordinal) + $mode = if ($executable) { 0x81ED } else { 0x81A4 } + $entry.ExternalAttributes = $mode -shl 16 + $input = [IO.File]::OpenRead($file.FullName) + $output = $entry.Open() + try { $input.CopyTo($output) } + finally { $output.Dispose(); $input.Dispose() } + } + $marker = $archive.CreateEntry( + 'campaign-la-fixture-release.txt', + [IO.Compression.CompressionLevel]::Optimal) + $marker.LastWriteTime = $fixedTimestamp + $marker.ExternalAttributes = 0x81A4 -shl 16 + $writer = [IO.StreamWriter]::new( + $marker.Open(), + [Text.UTF8Encoding]::new($false)) + try { + $writer.NewLine = "`n" + $writer.Write("release=$ReleaseLabel`npayload=$PayloadKind`nrid=$Rid`n") + } + finally { $writer.Dispose() } + } + finally { $archive.Dispose() } + } + finally { $stream.Dispose() } + Set-DeterministicZipHostPlatform $Destination +} + +function Get-Artifact([string]$Path, [string]$Url) { + $item = Get-Item -LiteralPath $Path + return [ordered]@{ + url = $Url + sha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() + size = $item.Length + } +} + +$releaseDefinitions = @( + [pscustomobject]@{ Label = 'A'; Version = $VersionA }, + [pscustomobject]@{ Label = 'B'; Version = $VersionB } +) +foreach ($release in $releaseDefinitions) { + $releaseRoot = Join-Path $OutputDirectory $release.Label + foreach ($rid in @('win-x64', 'linux-x64')) { + New-DeterministicZip ` + $sources["$($release.Label)-client-$rid"] ` + (Join-Path $releaseRoot "client-$rid.zip") ` + $release.Label 'client' $rid + New-DeterministicZip ` + $sources["$($release.Label)-launcher-$rid"] ` + (Join-Path $releaseRoot "launcher-$rid.zip") ` + $release.Label 'launcher' $rid + } + $baseUri = "http://127.0.0.1:$Port/$($release.Label)" + $manifest = [ordered]@{ + schemaVersion = 1 + version = $release.Version + minimumLauncherVersion = $MinimumLauncherVersion + clients = [ordered]@{ + 'win-x64' = Get-Artifact ` + (Join-Path $releaseRoot 'client-win-x64.zip') ` + "$baseUri/client-win-x64.zip" + 'linux-x64' = Get-Artifact ` + (Join-Path $releaseRoot 'client-linux-x64.zip') ` + "$baseUri/client-linux-x64.zip" + } + launchers = [ordered]@{ + 'win-x64' = Get-Artifact ` + (Join-Path $releaseRoot 'launcher-win-x64.zip') ` + "$baseUri/launcher-win-x64.zip" + 'linux-x64' = Get-Artifact ` + (Join-Path $releaseRoot 'launcher-linux-x64.zip') ` + "$baseUri/launcher-linux-x64.zip" + } + } + [IO.File]::WriteAllText( + (Join-Path $releaseRoot 'manifest.json'), + ($manifest | ConvertTo-Json -Depth 8 -Compress), + [Text.UTF8Encoding]::new($false)) +} +[IO.File]::WriteAllText( + (Join-Path $OutputDirectory 'active-release.txt'), + 'A', + [Text.Encoding]::ASCII) + +$server = @' +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$Root, + [Parameter(Mandatory = $true)][int]$Port, + [ValidateRange(0, 1000000)][int]$MaximumRequests = 0 +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$expectedRoot = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($PSScriptRoot)) +$Root = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Root)) +$pathComparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase +} else { [StringComparison]::Ordinal } +if (-not [string]::Equals($Root, $expectedRoot, $pathComparison)) { + throw '-Root must be the directory containing serve-fixture.ps1.' +} +$prefix = "http://127.0.0.1:$Port/" +$listener = [Net.HttpListener]::new() +$listener.Prefixes.Add($prefix) +$listener.Start() +Write-Host "Campaign LA fixture listening on $prefix" +$servedRequests = 0 +try { + while ($listener.IsListening) { + $context = $listener.GetContext() + try { + if ($context.Request.HttpMethod -cne 'GET') { + $context.Response.StatusCode = 405 + continue + } + $relative = [Uri]::UnescapeDataString($context.Request.Url.AbsolutePath.TrimStart('/')) + if ($relative -ceq 'manifest.json') { + $active = (Get-Content -LiteralPath (Join-Path $Root 'active-release.txt') -Raw).Trim() + if ($active -notin @('A', 'B')) { throw 'active-release.txt must contain A or B.' } + $relative = "$active/manifest.json" + } + if ([string]::IsNullOrWhiteSpace($relative) -or $relative.Contains('..')) { + $context.Response.StatusCode = 404 + continue + } + $path = [IO.Path]::GetFullPath((Join-Path $Root $relative)) + if (-not $path.StartsWith($Root + [IO.Path]::DirectorySeparatorChar, [StringComparison]::Ordinal) -or + -not (Test-Path -LiteralPath $path -PathType Leaf)) { + $context.Response.StatusCode = 404 + continue + } + $context.Response.ContentType = if ($path.EndsWith('.json', [StringComparison]::Ordinal)) { + 'application/json' + } else { 'application/zip' } + $context.Response.StatusCode = 200 + $context.Response.Headers['Cache-Control'] = 'no-store' + $context.Response.ContentLength64 = (Get-Item -LiteralPath $path).Length + $input = [IO.File]::OpenRead($path) + try { $input.CopyTo($context.Response.OutputStream) } + finally { $input.Dispose() } + } + catch { + $context.Response.StatusCode = 500 + Write-Error $_ + } + finally { + $context.Response.Close() + $servedRequests++ + } + if ($MaximumRequests -gt 0 -and $servedRequests -ge $MaximumRequests) { + break + } + } +} +finally { $listener.Close() } +'@ +[IO.File]::WriteAllText( + (Join-Path $OutputDirectory 'serve-fixture.ps1'), + $server.Replace("`r`n", "`n"), + [Text.UTF8Encoding]::new($false)) + +$selector = @' +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][ValidateSet('A', 'B')][string]$Release, + [string]$Root = $PSScriptRoot +) +Set-StrictMode -Version Latest +$expectedRoot = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($PSScriptRoot)) +$Root = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Root)) +$pathComparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase +} else { [StringComparison]::Ordinal } +if (-not [string]::Equals($Root, $expectedRoot, $pathComparison)) { + throw '-Root must be the directory containing set-active-release.ps1.' +} +$path = Join-Path $Root 'active-release.txt' +$temporary = "$path.$([Guid]::NewGuid().ToString('N')).tmp" +try { + [IO.File]::WriteAllText($temporary, $Release, [Text.Encoding]::ASCII) + [IO.File]::Move($temporary, $path, $true) +} +finally { + if ([IO.File]::Exists($temporary)) { [IO.File]::Delete($temporary) } +} +Write-Host "Campaign LA fixture active release: $Release" +'@ +[IO.File]::WriteAllText( + (Join-Path $OutputDirectory 'set-active-release.ps1'), + $selector.Replace("`r`n", "`n"), + [Text.UTF8Encoding]::new($false)) + +$inventoryPaths = [string[]]@(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse | + Where-Object { $_.Name -ne 'fixture-report.json' } | + ForEach-Object { + [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/') + }) +[Array]::Sort($inventoryPaths, [StringComparer]::Ordinal) +$inventory = @($inventoryPaths | ForEach-Object { + $fullPath = Join-Path $OutputDirectory $_.Replace('/', [IO.Path]::DirectorySeparatorChar) + $item = Get-Item -LiteralPath $fullPath + [ordered]@{ + path = $_ + size = $item.Length + sha256 = (Get-FileHash -LiteralPath $fullPath -Algorithm SHA256).Hash.ToLowerInvariant() + } + }) +$report = [ordered]@{ + schemaVersion = 1 + kind = 'campaign-la-update-fixture' + versions = [ordered]@{ A = $VersionA; B = $VersionB } + minimumLauncherVersion = $MinimumLauncherVersion + manifestUri = "http://127.0.0.1:$Port/manifest.json" + loopbackOnly = $true + initialRelease = 'A' + sourceDirectories = $sources + artifacts = $inventory +} +$report | ConvertTo-Json -Depth 8 | + Set-Content -LiteralPath (Join-Path $OutputDirectory 'fixture-report.json') -Encoding utf8NoBOM +Write-Host "Campaign LA update fixture: $OutputDirectory" diff --git a/tools/run-campaign-la-preflight.ps1 b/tools/run-campaign-la-preflight.ps1 new file mode 100644 index 00000000..2966ed59 --- /dev/null +++ b/tools/run-campaign-la-preflight.ps1 @@ -0,0 +1,530 @@ +<# +.SYNOPSIS + Campaign LA11 display-free, connection-free automated preflight. + +.DESCRIPTION + Runs the exact Release and portability ladder used before the launcher + user gate. It never starts App/Headless in connected mode, never opens a + window, never reads credentials, and never bakes retail DATs. All logs and + publishes are contained beneath one logs/campaign-la-gate- + directory. Use -DryRun to emit the complete command matrix without + executing it. +#> +[CmdletBinding()] +param( + [string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path, + [Parameter(Mandatory = $true)][string]$AllowedOutputRoot, + [string]$OutputDirectory, + [switch]$DryRun, + [switch]$IncludeInstalledDat, + [string]$InstalledDatDirectory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'Campaign LA preflight requires PowerShell 7 or newer.' +} + +$Repository = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($Repository)) +if (-not (Test-Path -LiteralPath (Join-Path $Repository 'AcDream.slnx') -PathType Leaf)) { + throw "Repository does not contain AcDream.slnx: $Repository" +} + +function Assert-NoReparseAncestry([string]$Path, [string]$Description) { + $cursor = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Path)) + while (-not (Test-Path -LiteralPath $cursor)) { + $parent = [IO.Path]::GetDirectoryName($cursor) + if ([string]::IsNullOrEmpty($parent) -or $parent -ceq $cursor) { break } + $cursor = $parent + } + while (-not [string]::IsNullOrEmpty($cursor)) { + $item = Get-Item -LiteralPath $cursor -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "$Description has a reparse point in its ancestry." + } + $parent = [IO.Directory]::GetParent($cursor) + if ($null -eq $parent) { break } + $cursor = $parent.FullName + } +} + +function Test-SameOrDescendant([string]$Path, [string]$Ancestor) { + $comparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase + } else { [StringComparison]::Ordinal } + if ([string]::Equals($Path, $Ancestor, $comparison)) { return $true } + return $Path.StartsWith( + $Ancestor + [IO.Path]::DirectorySeparatorChar, + $comparison) +} + +if (-not [IO.Path]::IsPathFullyQualified($AllowedOutputRoot)) { + throw '-AllowedOutputRoot must be absolute.' +} +$AllowedOutputRoot = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($AllowedOutputRoot)) +if (-not (Test-Path -LiteralPath $AllowedOutputRoot -PathType Container)) { + throw '-AllowedOutputRoot must be an existing campaign gate/log directory.' +} +Assert-NoReparseAncestry $AllowedOutputRoot 'Allowed output root' +$comparison = if ($IsWindows) { + [StringComparison]::OrdinalIgnoreCase +} else { [StringComparison]::Ordinal } +$homeDirectory = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath([Environment]::GetFolderPath( + [Environment+SpecialFolder]::UserProfile))) +if ([string]::Equals($AllowedOutputRoot, $Repository, $comparison) -or + [string]::Equals($AllowedOutputRoot, $homeDirectory, $comparison)) { + throw '-AllowedOutputRoot cannot be the repository root or user home.' +} +$repositoryLogs = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath((Join-Path $Repository 'logs'))) +$allowedLeaf = [IO.Path]::GetFileName($AllowedOutputRoot) +$allowedInRepository = Test-SameOrDescendant $AllowedOutputRoot $Repository +if ($allowedInRepository -and + -not (Test-SameOrDescendant $AllowedOutputRoot $repositoryLogs)) { + throw '-AllowedOutputRoot inside the repository must be below its logs directory.' +} +if (-not [string]::Equals($AllowedOutputRoot, $repositoryLogs, $comparison) -and + -not $allowedLeaf.StartsWith('campaign-la-', [StringComparison]::Ordinal)) { + throw '-AllowedOutputRoot must be the repository logs root or a campaign-la-* gate root.' +} +if ($IncludeInstalledDat) { + if ([string]::IsNullOrWhiteSpace($InstalledDatDirectory) -or + -not [IO.Path]::IsPathFullyQualified($InstalledDatDirectory)) { + throw '-IncludeInstalledDat requires an absolute -InstalledDatDirectory.' + } + $InstalledDatDirectory = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($InstalledDatDirectory)) + foreach ($file in @( + 'client_portal.dat', + 'client_cell_1.dat', + 'client_highres.dat', + 'client_local_English.dat')) { + if (-not (Test-Path -LiteralPath (Join-Path $InstalledDatDirectory $file) -PathType Leaf)) { + throw "Installed DAT directory is missing $file." + } + } +} + +$stamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss') +if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { + $OutputDirectory = Join-Path $AllowedOutputRoot "campaign-la-preflight-$stamp" +} +elseif (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) { + throw '-OutputDirectory must be absolute when supplied.' +} +$OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator( + [IO.Path]::GetFullPath($OutputDirectory)) +if (-not (Test-SameOrDescendant $OutputDirectory $AllowedOutputRoot) -or + [string]::Equals($OutputDirectory, $AllowedOutputRoot, $comparison)) { + throw '-OutputDirectory must be a strict descendant of -AllowedOutputRoot.' +} +if ([string]::Equals($OutputDirectory, $Repository, $comparison) -or + [string]::Equals($OutputDirectory, $homeDirectory, $comparison)) { + throw '-OutputDirectory cannot be the repository root or user home.' +} +if (Test-Path -LiteralPath $OutputDirectory) { + throw '-OutputDirectory must be fresh and must not already exist.' +} +Assert-NoReparseAncestry $OutputDirectory 'Output directory' +$logsDirectory = Join-Path $OutputDirectory 'commands' +$publishDirectory = Join-Path $OutputDirectory 'publish' +$null = New-Item -ItemType Directory -Path $logsDirectory + +$commandResults = [Collections.Generic.List[object]]::new() +$failures = [Collections.Generic.List[string]]::new() +$startedUtc = [DateTime]::UtcNow + +function Protect-Text([string]$Text) { + if ($null -eq $Text) { return '' } + $protected = $Text + $protected = [Text.RegularExpressions.Regex]::Replace( + $protected, + '(?i)(--password|-password)(\s+|=)([^\s"'']+)', + '$1$2') + $protected = [Text.RegularExpressions.Regex]::Replace( + $protected, + '(?i)\b(password|passwd|secret|token|credential|api[_-]?key)(\s*[:=]\s*)([^\s,;]+)', + '$1$2') + $protected = [Text.RegularExpressions.Regex]::Replace( + $protected, + '(?i)(https?://)[^/\s:@]+:[^@\s/]+@', + '$1@') + $protected = [Text.RegularExpressions.Regex]::Replace( + $protected, + '(?i)([?&](?:token|secret|password|credential|api[_-]?key)=)[^&\s]+', + '$1') + $protected = [Text.RegularExpressions.Regex]::Replace( + $protected, + '(?i)("(?:password|credential|secret|token)"\s*:\s*")[^"]*(")', + '$1$2') + return $protected +} + +function Format-Command([string]$FilePath, [string[]]$Arguments) { + $parts = [Collections.Generic.List[string]]::new() + $parts.Add($FilePath) + foreach ($argument in $Arguments) { + if ($argument -match '[\s"]') { + $parts.Add('"' + $argument.Replace('"', '\"') + '"') + } + else { $parts.Add($argument) } + } + return $parts -join ' ' +} + +function Add-PlannedCommand( + [string]$Name, + [string]$FilePath, + [string[]]$Arguments, + [Collections.IDictionary]$Environment = @{}) { + $commandResults.Add([ordered]@{ + name = $Name + command = Format-Command $FilePath $Arguments + status = 'planned' + startedUtc = $null + durationSeconds = 0 + exitCode = $null + stdout = $null + stderr = $null + environmentKeys = @($Environment.Keys | Sort-Object) + }) +} + +function Invoke-GateCommand( + [string]$Name, + [string]$FilePath, + [string[]]$Arguments, + [Collections.IDictionary]$Environment = @{}) { + if ($DryRun) { + Add-PlannedCommand $Name $FilePath $Arguments $Environment + return + } + + $safeName = $Name -replace '[^A-Za-z0-9_.-]', '-' + $stdoutRelative = "commands/$safeName.out.log" + $stderrRelative = "commands/$safeName.err.log" + $stdoutPath = Join-Path $OutputDirectory $stdoutRelative + $stderrPath = Join-Path $OutputDirectory $stderrRelative + $begin = [DateTime]::UtcNow + $watch = [Diagnostics.Stopwatch]::StartNew() + $exitCode = 74 + try { + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $FilePath + $startInfo.WorkingDirectory = $Repository + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($argument in $Arguments) { $startInfo.ArgumentList.Add($argument) } + foreach ($key in @($startInfo.Environment.Keys)) { + if ($key.StartsWith('ACDREAM_', [StringComparison]::OrdinalIgnoreCase)) { + $startInfo.Environment.Remove($key) + } + } + foreach ($entry in $Environment.GetEnumerator()) { + $startInfo.Environment[[string]$entry.Key] = [string]$entry.Value + } + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + if (-not $process.Start()) { throw "Could not start $FilePath." } + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $stdout = $stdoutTask.GetAwaiter().GetResult() + $stderr = $stderrTask.GetAwaiter().GetResult() + $exitCode = $process.ExitCode + $process.Dispose() + [IO.File]::WriteAllText($stdoutPath, (Protect-Text $stdout)) + [IO.File]::WriteAllText($stderrPath, (Protect-Text $stderr)) + } + catch { + [IO.File]::WriteAllText($stderrPath, (Protect-Text ($_ | Out-String))) + } + finally { + $watch.Stop() + $commandResults.Add([ordered]@{ + name = $Name + command = Format-Command $FilePath $Arguments + status = if ($exitCode -eq 0) { 'passed' } else { 'failed' } + startedUtc = $begin.ToString('O') + durationSeconds = [Math]::Round($watch.Elapsed.TotalSeconds, 3) + exitCode = $exitCode + stdout = $stdoutRelative + stderr = $stderrRelative + environmentKeys = @($Environment.Keys | Sort-Object) + }) + } + if ($exitCode -ne 0) { + throw "Preflight command '$Name' failed with exit code $exitCode." + } +} + +function Add-InternalCheck([string]$Name, [scriptblock]$Action) { + if ($DryRun) { + $commandResults.Add([ordered]@{ + name = $Name; command = ''; status = 'planned' + startedUtc = $null; durationSeconds = 0; exitCode = $null + stdout = $null; stderr = $null; environmentKeys = @() + }) + return + } + $begin = [DateTime]::UtcNow + $watch = [Diagnostics.Stopwatch]::StartNew() + $exitCode = 0 + try { & $Action } + catch { $exitCode = 1; throw } + finally { + $watch.Stop() + $commandResults.Add([ordered]@{ + name = $Name; command = '' + status = if ($exitCode -eq 0) { 'passed' } else { 'failed' } + startedUtc = $begin.ToString('O') + durationSeconds = [Math]::Round($watch.Elapsed.TotalSeconds, 3) + exitCode = $exitCode; stdout = $null; stderr = $null; environmentKeys = @() + }) + } +} + +function Invoke-DotNet([string]$Name, [string[]]$Arguments) { + Invoke-GateCommand $Name 'dotnet' $Arguments +} + +$portableBuildProjects = @( + 'src/AcDream.Platform/AcDream.Platform.csproj', + 'src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj', + 'src/AcDream.Bake/AcDream.Bake.csproj', + 'src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj', + 'src/AcDream.Core/AcDream.Core.csproj', + 'src/AcDream.Core.Net/AcDream.Core.Net.csproj', + 'src/AcDream.Content/AcDream.Content.csproj', + 'src/AcDream.Runtime/AcDream.Runtime.csproj', + 'src/AcDream.Headless/AcDream.Headless.csproj' +) +$portableTestProjects = @( + 'tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj', + 'tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj', + 'tests/AcDream.Bake.Tests/AcDream.Bake.Tests.csproj', + 'tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj', + 'tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj', + 'tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj', + 'tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj' +) + +try { + Invoke-DotNet 'release-build' @( + 'build', 'AcDream.slnx', '-c', 'Release', '--nologo', '-m:1') + Invoke-GateCommand 'campaign-la-gate-helper-contracts' ` + ([Environment]::ProcessPath ?? + $(throw 'The PowerShell process path is unavailable.')) ` + @( + '-NoProfile', + '-File', 'tools/test-campaign-la-gate-helpers.ps1', + '-Repository', $Repository, + '-OutputDirectory', (Join-Path $OutputDirectory 'helper-contracts')) + Invoke-GateCommand 'campaign-la-script-safety-contracts' ` + ([Environment]::ProcessPath ?? + $(throw 'The PowerShell process path is unavailable.')) ` + @( + '-NoProfile', + '-File', 'tools/test-campaign-la-script-safety.ps1', + '-Repository', $Repository, + '-OutputDirectory', (Join-Path $OutputDirectory 'script-safety')) + Invoke-DotNet 'release-tests-serial' @( + 'test', 'AcDream.slnx', '-c', 'Release', '--no-build', '--nologo', '-m:1', + '--', 'RunConfiguration.MaxCpuCount=1') + Invoke-DotNet 'focused-launcher-updater-core' @( + 'test', 'tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj', + '-c', 'Release', '--no-build', '--nologo', + '--filter', 'FullyQualifiedName~Updates') + Invoke-DotNet 'focused-launcher-updater-ui' @( + 'test', 'tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj', + '-c', 'Release', '--no-build', '--nologo', + '--filter', 'FullyQualifiedName~LauncherUpdateViewModelTests|FullyQualifiedName~LauncherStartupOptionsTests') + + foreach ($project in $portableBuildProjects) { + $leaf = [IO.Path]::GetFileNameWithoutExtension($project) + Invoke-DotNet "portable-build-$leaf" @( + 'build', $project, '-c', 'Release', '--no-restore', '--nologo', '-m:1') + } + foreach ($project in $portableTestProjects) { + $leaf = [IO.Path]::GetFileNameWithoutExtension($project) + Invoke-DotNet "portable-test-$leaf" @( + 'test', $project, '-c', 'Release', '--no-build', '--nologo', + '--', 'RunConfiguration.MaxCpuCount=1') + } + + $headlessValidationConfig = Join-Path $OutputDirectory 'headless-k0.json' + Add-InternalCheck 'portable-headless-write-empty-config' { + [IO.File]::WriteAllText( + $headlessValidationConfig, + '{"version":1,"sessions":[]}', + [Text.UTF8Encoding]::new($false)) + } + Invoke-DotNet 'portable-headless-help-no-connect' @( + 'run', '--project', 'src/AcDream.Headless/AcDream.Headless.csproj', + '-c', 'Release', '--no-build', '--', '--help') + Invoke-DotNet 'portable-headless-validate-empty-no-connect' @( + 'run', '--project', 'src/AcDream.Headless/AcDream.Headless.csproj', + '-c', 'Release', '--no-build', '--', + 'validate', '--config', $headlessValidationConfig) + Add-InternalCheck 'portable-headless-native-permission' { + if (-not $IsWindows) { + $headlessExecutable = Join-Path ` + $Repository 'src/AcDream.Headless/bin/Release/net10.0/acdream-headless' + if (-not (Test-Path -LiteralPath $headlessExecutable -PathType Leaf)) { + throw 'The native Headless build output is missing.' + } + $mode = [IO.File]::GetUnixFileMode($headlessExecutable) + if (($mode -band [IO.UnixFileMode]::UserExecute) -eq 0) { + throw 'The native Headless build output is not executable.' + } + } + } + + foreach ($rid in @('win-x64', 'linux-x64')) { + $destination = Join-Path $publishDirectory $rid + Invoke-DotNet "publish-launcher-$rid" @( + 'publish', 'src/AcDream.Launcher/AcDream.Launcher.csproj', + '-c', 'Release', '-r', $rid, '--self-contained', 'true', + '-p:PublishSingleFile=true', '-o', $destination, '--nologo') + Add-InternalCheck "publish-contract-$rid" { + $suffix = if ($rid.StartsWith('win-', [StringComparison]::Ordinal)) { '.exe' } else { '' } + foreach ($name in @("acdream-launcher$suffix", "acdream-bake$suffix")) { + if (-not (Test-Path -LiteralPath (Join-Path $destination $name) -PathType Leaf)) { + throw "$rid publish is missing $name." + } + } + if (Test-Path -LiteralPath (Join-Path $destination 'acdream-launcher.dll')) { + throw "$rid launcher publish is not single-file." + } + if (Test-Path -LiteralPath (Join-Path $destination 'acdream-bake.dll')) { + throw "$rid bake publish is not single-file." + } + if (-not $IsWindows -and $rid -eq 'linux-x64') { + $mode = [IO.File]::GetUnixFileMode((Join-Path $destination 'acdream-launcher')) + if (($mode -band [IO.UnixFileMode]::UserExecute) -eq 0) { + throw 'linux-x64 launcher is not executable.' + } + } + } + } + + $nativeRid = if ($IsWindows) { 'win-x64' } else { 'linux-x64' } + $nativeSuffix = if ($IsWindows) { '.exe' } else { '' } + $nativeRoot = Join-Path $publishDirectory $nativeRid + $bogusRoot = if ($IsWindows) { 'Z:\definitely-not-installed' } else { '/definitely-not-installed' } + $bogusEnvironment = @{ + DOTNET_ROOT = $bogusRoot + DOTNET_ROOT_X64 = $bogusRoot + DOTNET_MULTILEVEL_LOOKUP = '0' + } + Invoke-GateCommand 'native-launcher-bogus-dotnet-root' ` + (Join-Path $nativeRoot "acdream-launcher$nativeSuffix") ` + @('--verify-publish') $bogusEnvironment + Invoke-GateCommand 'native-bake-bogus-dotnet-root' ` + (Join-Path $nativeRoot "acdream-bake$nativeSuffix") ` + @('--help') $bogusEnvironment + + if ($IncludeInstalledDat) { + $datEnvironment = @{ + ACDREAM_DAT_DIR = $InstalledDatDirectory + ACDREAM_PROBE_LIVE_MOUNT = '1' + } + $datResults = Join-Path $OutputDirectory 'installed-dat-results' + Invoke-GateCommand 'installed-dat-character-management-readonly' 'dotnet' @( + 'test', 'tests/AcDream.App.Tests/AcDream.App.Tests.csproj', + '-c', 'Release', '--no-build', '--nologo', + '--filter', 'FullyQualifiedName~CharacterManagementLiveDatTests', + '--results-directory', $datResults, + '--logger', 'trx;LogFileName=character-management.trx') $datEnvironment + Add-InternalCheck 'installed-dat-character-management-require-pass' { + $trx = Join-Path $datResults 'character-management.trx' + if (-not (Test-Path -LiteralPath $trx -PathType Leaf)) { + throw 'CharacterManagementLiveDatTests did not produce a TRX result.' + } + [xml]$result = Get-Content -LiteralPath $trx -Raw + $outcomes = @($result.TestRun.Results.UnitTestResult | ForEach-Object { $_.outcome }) + if ($outcomes.Count -eq 0 -or $outcomes -ccontains 'NotExecuted' -or + @($outcomes | Where-Object { $_ -cne 'Passed' }).Count -gt 0) { + throw "CharacterManagementLiveDatTests must pass (not skip): $($outcomes -join ',')." + } + } + Invoke-GateCommand 'installed-dat-action-map-readonly' 'dotnet' @( + 'test', 'tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj', + '-c', 'Release', '--no-build', '--nologo', + '--filter', 'FullyQualifiedName~RetailActionMapReader_LiveDatTests') $datEnvironment + Invoke-GateCommand 'installed-dat-portal-assets-readonly' 'dotnet' @( + 'test', 'tests/AcDream.App.Tests/AcDream.App.Tests.csproj', + '-c', 'Release', '--no-build', '--nologo', + '--filter', 'FullyQualifiedName~PortalTunnelAssetTests.InstalledDat_ResolvesRetailPortalSetupAndAnimation') $datEnvironment + } +} +catch { + $failures.Add((Protect-Text ($_ | Out-String)).Trim()) +} +finally { + $finishedUtc = [DateTime]::UtcNow + $head = (& git -C $Repository rev-parse HEAD).Trim() + $dirtyLines = @(& git -C $Repository status --porcelain=v1 --untracked-files=all) + $artifacts = @() + if (-not $DryRun) { + [string[]]$artifactPaths = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse | + Where-Object { $_.FullName -ne (Join-Path $OutputDirectory 'report.json') } | + ForEach-Object { + [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/') + }) + [Array]::Sort($artifactPaths, [StringComparer]::Ordinal) + $artifacts = @($artifactPaths | ForEach-Object { + $fullPath = Join-Path $OutputDirectory $_.Replace( + '/', [IO.Path]::DirectorySeparatorChar) + $item = Get-Item -LiteralPath $fullPath + [ordered]@{ + path = $_ + size = $item.Length + sha256 = (Get-FileHash -LiteralPath $fullPath -Algorithm SHA256).Hash.ToLowerInvariant() + } + }) + } + $failedCommands = @($commandResults | Where-Object { $_.status -eq 'failed' }) + $report = [ordered]@{ + schemaVersion = 1 + kind = 'campaign-la-automated-preflight' + dryRun = [bool]$DryRun + success = ($failures.Count -eq 0 -and $failedCommands.Count -eq 0) + repository = $Repository + allowedOutputRoot = $AllowedOutputRoot + head = $head + dirty = ($dirtyLines.Count -gt 0) + dirtyPaths = @($dirtyLines | ForEach-Object { Protect-Text $_ }) + platform = [ordered]@{ + os = [Runtime.InteropServices.RuntimeInformation]::OSDescription + architecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + processArchitecture = [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString() + rid = [Runtime.InteropServices.RuntimeInformation]::RuntimeIdentifier + framework = [Runtime.InteropServices.RuntimeInformation]::FrameworkDescription + powershell = $PSVersionTable.PSVersion.ToString() + } + startedUtc = $startedUtc.ToString('O') + finishedUtc = $finishedUtc.ToString('O') + durationSeconds = [Math]::Round(($finishedUtc - $startedUtc).TotalSeconds, 3) + installedDatIncluded = [bool]$IncludeInstalledDat + commands = @($commandResults) + failures = @($failures) + redaction = [ordered]@{ + applied = $true + inheritedAcdreamEnvironmentCleared = $true + inheritedEnvironmentValuesRead = $false + credentialArgumentsAllowed = $false + } + artifacts = $artifacts + } + $reportPath = Join-Path $OutputDirectory 'report.json' + $report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $reportPath -Encoding utf8NoBOM + Write-Host "Campaign LA preflight report: $reportPath" + if (-not $report.success) { exit 1 } +} diff --git a/tools/test-campaign-la-gate-helpers.ps1 b/tools/test-campaign-la-gate-helpers.ps1 new file mode 100644 index 00000000..f1751988 --- /dev/null +++ b/tools/test-campaign-la-gate-helpers.ps1 @@ -0,0 +1,367 @@ +<# +.SYNOPSIS + Connection-free contract tests for Campaign LA gate evidence helpers. +#> +[CmdletBinding()] +param( + [string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path, + [Parameter(Mandatory = $true)][string]$OutputDirectory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'Campaign LA helper tests require PowerShell 7 or newer.' +} +$Repository = [IO.Path]::GetFullPath($Repository) +if (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) { + throw '-OutputDirectory must be absolute.' +} +$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory) +if (Test-Path -LiteralPath $OutputDirectory) { + throw '-OutputDirectory must be fresh.' +} +$null = New-Item -ItemType Directory -Path $OutputDirectory +$pwsh = [Environment]::ProcessPath +if ([string]::IsNullOrWhiteSpace($pwsh)) { + throw 'The PowerShell process path is unavailable.' +} +$validator = Join-Path $Repository 'tools/test-campaign-la-session-status.ps1' +$capture = Join-Path $Repository 'tools/capture-campaign-la-session-process.ps1' +. (Join-Path $Repository 'tools/CampaignLaProcessCorrelation.ps1') + +function Write-Profile([string]$Path, [string]$Secret) { + $document = [ordered]@{ + version = 1 + servers = @([ordered]@{ + name = 'fixture' + host = '127.0.0.1' + port = 9000 + accounts = @([ordered]@{ + account = 'fixture-account' + password = $Secret + characters = @() + }) + }) + } + [IO.File]::WriteAllText( + $Path, + ($document | ConvertTo-Json -Depth 8), + [Text.UTF8Encoding]::new($false)) + if ($IsLinux) { + [IO.File]::SetUnixFileMode( + $Path, + [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite) + } +} + +function New-GuiEvents { + $begin = [DateTimeOffset]::ParseExact( + '2026-08-15T10:00:00.0000000+00:00', + 'O', + [Globalization.CultureInfo]::InvariantCulture) + $session = 'fixture-session' + return @( + [ordered]@{ v = 1; e = 'started'; t = $begin.ToString('O'); sessionId = $session }, + [ordered]@{ v = 1; e = 'pluginLoaded'; t = $begin.AddSeconds(1).ToString('O'); sessionId = $session; plugin = 'smoke' }, + [ordered]@{ v = 1; e = 'pluginFailed'; t = $begin.AddSeconds(2).ToString('O'); sessionId = $session; plugin = 'optional'; error = 'allowed fixture failure' }, + [ordered]@{ v = 1; e = 'connected'; t = $begin.AddSeconds(3).ToString('O'); sessionId = $session }, + [ordered]@{ + v = 1; e = 'characterList'; t = $begin.AddSeconds(4).ToString('O') + sessionId = $session; accountName = 'fixture-account'; slotCount = 1 + characters = @([ordered]@{ id = 1342177290; name = 'Fixture'; secondsGreyedOut = 0 }) + }, + [ordered]@{ v = 1; e = 'enteredWorld'; t = $begin.AddSeconds(5).ToString('O'); sessionId = $session; characterId = 1342177290; characterName = 'Fixture' }, + [ordered]@{ v = 1; e = 'loginCommandFailed'; t = $begin.AddSeconds(6).ToString('O'); sessionId = $session; commandIndex = 0; command = '/fixture'; error = 'allowed fixture failure' }, + [ordered]@{ v = 1; e = 'disconnected'; t = $begin.AddSeconds(7).ToString('O'); sessionId = $session; reason = 'stopped' }, + [ordered]@{ v = 1; e = 'exited'; t = $begin.AddSeconds(8).ToString('O'); sessionId = $session; code = 0; reason = 'graceful' } + ) +} + +function Write-Events([string]$Path, [object[]]$Events) { + $lines = @($Events | ForEach-Object { $_ | ConvertTo-Json -Depth 8 -Compress }) + [IO.File]::WriteAllLines($Path, $lines, [Text.UTF8Encoding]::new($false)) +} + +function Invoke-Validator( + [string]$Status, + [string]$Profile, + [string]$Report, + [string]$ProcessCapture, + [bool]$ShouldPass) { + $arguments = [Collections.Generic.List[string]]::new() + foreach ($value in @( + '-NoProfile', '-File', $validator, + '-StatusFile', $Status, + '-Mode', 'gui', + '-ProcessCapturePath', $ProcessCapture, + '-CredentialProfilePath', $Profile, + '-ExpectedPlugin', 'smoke', + '-AllowPluginFailure', + '-AllowLoginCommandFailure', + '-ReportPath', $Report)) { + $arguments.Add($value) + } + $start = [Diagnostics.ProcessStartInfo]::new($pwsh) + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + foreach ($argument in $arguments) { $start.ArgumentList.Add($argument) } + $process = [Diagnostics.Process]::Start($start) + if ($null -eq $process) { throw 'Could not start status validator.' } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $outText = $stdout.GetAwaiter().GetResult() + $errorText = $stderr.GetAwaiter().GetResult() + $exitCode = $process.ExitCode + $process.Dispose() + if (($exitCode -eq 0) -ne $ShouldPass) { + throw "Validator result mismatch (exit $exitCode). $outText $errorText" + } +} + +function Write-ProcessCapture( + [string]$Path, + [int]$ProcessId, + [string]$ProcessInstanceIdentity, + [string]$SessionConfigPath, + [string]$CommandFingerprint = ('a' * 64)) { + $sessionId = [IO.Path]::GetFileName( + [IO.Path]::GetDirectoryName($SessionConfigPath)) + $document = [ordered]@{ + schemaVersion = 2 + kind = 'campaign-la-session-process-capture' + processId = $ProcessId + processInstanceIdentity = $ProcessInstanceIdentity + sessionId = $sessionId + sessionConfigPath = [IO.Path]::GetFullPath($SessionConfigPath) + commandLineFingerprintSha256 = $CommandFingerprint + capturedUtc = [DateTime]::UtcNow.ToString('O') + } + [IO.File]::WriteAllText( + $Path, + ($document | ConvertTo-Json -Depth 4), + [Text.UTF8Encoding]::new($false)) +} + +$quickInfo = [Diagnostics.ProcessStartInfo]::new($pwsh) +$quickInfo.UseShellExecute = $false +$quickInfo.ArgumentList.Add('-NoProfile') +$quickInfo.ArgumentList.Add('-Command') +$quickInfo.ArgumentList.Add('exit 0') +$quick = [Diagnostics.Process]::Start($quickInfo) +if ($null -eq $quick) { throw 'Could not create an exited PID fixture.' } +$goneProcessId = $quick.Id +$quick.WaitForExit() +$quick.Dispose() + +$sessionRoot = Join-Path $OutputDirectory 'fixture-session' +$null = New-Item -ItemType Directory -Path $sessionRoot +$sessionConfig = Join-Path $sessionRoot 'session.json' +[IO.File]::WriteAllText($sessionConfig, '{}', [Text.UTF8Encoding]::new($false)) +$syntheticIdentity = if ($IsWindows) { + 'windows-creation-v1:638000000000000000' +} else { 'linux-proc-start-v1:00000000-0000-0000-0000-000000000001:1' } +$goneCapture = Join-Path $OutputDirectory 'gone-process.capture.json' +Write-ProcessCapture ` + $goneCapture $goneProcessId $syntheticIdentity $sessionConfig + +$profile = Join-Path $OutputDirectory 'launcher-profiles.json' +Write-Profile $profile 'la11-positive-secret-7E477A2D' +$positiveStatus = Join-Path $OutputDirectory 'positive.jsonl' +Write-Events $positiveStatus (New-GuiEvents) +Invoke-Validator ` + $positiveStatus $profile (Join-Path $OutputDirectory 'positive.validation.json') ` + $goneCapture $true + +$malformedCapture = Join-Path $OutputDirectory 'malformed-process.capture.json' +[IO.File]::WriteAllText( + $malformedCapture, + '{"schemaVersion":2}', + [Text.UTF8Encoding]::new($false)) +Invoke-Validator ` + $positiveStatus $profile (Join-Path $OutputDirectory 'malformed.validation.json') ` + $malformedCapture $false + +foreach ($reason in @('transport', 'reconnect', 'other')) { + $events = @(New-GuiEvents) + $events[7].reason = $reason + $path = Join-Path $OutputDirectory "reason-$reason.jsonl" + $report = Join-Path $OutputDirectory "reason-$reason.validation.json" + Write-Events $path $events + Invoke-Validator $path $profile $report $goneCapture $false + $result = Get-Content -LiteralPath $report -Raw | ConvertFrom-Json + if (-not ($result.failures -match 'disconnected reason')) { + throw "Disconnected reason '$reason' was not rejected by its exact assertion." + } +} + +$secretCases = @( + 'eventName', 'timestamp', 'sessionId', 'accountName', 'characterName', + 'enteredCharacterName', 'loadedPlugin', 'failedPlugin', 'pluginError', + 'command', 'commandError', 'disconnectedReason', 'exitReason') +foreach ($case in $secretCases) { + $secret = "la11-secret-$case-5A7D" + $caseProfile = Join-Path $OutputDirectory "secret-$case.profile.json" + Write-Profile $caseProfile $secret + $events = @(New-GuiEvents) + switch ($case) { + 'eventName' { $events[0].e = $secret } + 'timestamp' { $events[0].t = $secret } + 'sessionId' { foreach ($event in $events) { $event.sessionId = $secret } } + 'accountName' { $events[4].accountName = $secret } + 'characterName' { $events[4].characters[0].name = $secret } + 'enteredCharacterName' { $events[5].characterName = $secret } + 'loadedPlugin' { $events[1].plugin = $secret } + 'failedPlugin' { $events[2].plugin = $secret } + 'pluginError' { $events[2].error = $secret } + 'command' { $events[6].command = $secret } + 'commandError' { $events[6].error = $secret } + 'disconnectedReason' { $events[7].reason = $secret } + 'exitReason' { $events[8].reason = $secret } + } + $path = Join-Path $OutputDirectory "secret-$case.jsonl" + $report = Join-Path $OutputDirectory "secret-$case.validation.json" + Write-Events $path $events + Invoke-Validator $path $caseProfile $report $goneCapture $false + $result = Get-Content -LiteralPath $report -Raw | ConvertFrom-Json + if (-not ($result.failures -match 'credential value')) { + throw "Credential echo case '$case' was not rejected by recursive scanning." + } +} + +$fixtureSource = Join-Path ` + $Repository 'tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/bin/Release/net10.0' +$fixtureRoot = Join-Path $OutputDirectory 'process-fixture' +Copy-Item -LiteralPath $fixtureSource -Destination $fixtureRoot -Recurse +$sourceBase = 'AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder' +$suffix = if ($IsWindows) { '.exe' } else { '' } +$sourceHost = Join-Path $fixtureRoot "$sourceBase$suffix" +$sameNameHost = Join-Path $fixtureRoot "acdream-headless$suffix" +Copy-Item -LiteralPath $sourceHost -Destination $sameNameHost +foreach ($extension in @('.runtimeconfig.json', '.deps.json')) { + Copy-Item -LiteralPath (Join-Path $fixtureRoot "$sourceBase$extension") ` + -Destination (Join-Path $fixtureRoot "acdream-headless$extension") +} +if ($IsLinux) { + [IO.File]::SetUnixFileMode( + $sameNameHost, + [IO.File]::GetUnixFileMode($sourceHost)) +} + +$targetReady = Join-Path $OutputDirectory 'target.ready' +$targetRelease = Join-Path $OutputDirectory 'target.release' +$unrelatedReady = Join-Path $OutputDirectory 'unrelated.ready' +$unrelatedRelease = Join-Path $OutputDirectory 'unrelated.release' +$unrelatedSessionRoot = Join-Path $OutputDirectory 'unrelated-session' +$null = New-Item -ItemType Directory -Path $unrelatedSessionRoot +$unrelatedConfig = Join-Path $unrelatedSessionRoot 'session.json' +[IO.File]::WriteAllText($unrelatedConfig, '{}', [Text.UTF8Encoding]::new($false)) + +function Start-Fixture([string[]]$Arguments) { + $start = [Diagnostics.ProcessStartInfo]::new($sameNameHost) + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + foreach ($argument in $Arguments) { $start.ArgumentList.Add($argument) } + return [Diagnostics.Process]::Start($start) +} + +$target = Start-Fixture @( + 'hold-campaign-la-process', '--config', $sessionConfig, $targetReady, $targetRelease) +$unrelated = Start-Fixture @( + 'hold-campaign-la-process', '--config', $unrelatedConfig, + $unrelatedReady, $unrelatedRelease) +if ($null -eq $target -or $null -eq $unrelated) { + throw 'Could not start process-correlation fixtures.' +} +try { + $deadline = [DateTime]::UtcNow.AddSeconds(10) + while ((-not (Test-Path -LiteralPath $targetReady) -or + -not (Test-Path -LiteralPath $unrelatedReady)) -and + [DateTime]::UtcNow -lt $deadline) { + Start-Sleep -Milliseconds 50 + } + if (-not (Test-Path -LiteralPath $targetReady) -or + -not (Test-Path -LiteralPath $unrelatedReady)) { + throw 'Process-correlation fixtures did not become ready.' + } + + $captureReport = Join-Path $OutputDirectory 'process-capture.json' + & $pwsh -NoProfile -File $capture ` + -SessionConfigPath $sessionConfig -ReportPath $captureReport + if ($LASTEXITCODE -ne 0) { throw 'Process capture failed.' } + $captured = Get-Content -LiteralPath $captureReport -Raw | ConvertFrom-Json + if ([int]$captured.processId -ne $target.Id -or + [string]$captured.sessionConfigPath -cne $sessionConfig -or + [string]$captured.commandLineFingerprintSha256 -cnotmatch '^[0-9a-f]{64}$') { + throw 'Process capture did not return exact sanitized instance evidence.' + } + + $liveReport = Join-Path $OutputDirectory 'live-pid.validation.json' + Invoke-Validator ` + $positiveStatus $profile $liveReport $captureReport $false + $liveResult = Get-Content -LiteralPath $liveReport -Raw | ConvertFrom-Json + if (-not ($liveResult.failures -match 'process instance.*remains alive') -or + $liveResult.capturedProcessInstanceExited) { + throw 'A live exact child instance was not rejected by the terminal validator.' + } + + Set-Content -LiteralPath $targetRelease -Value 'release' -NoNewline + $target.WaitForExit() + Invoke-Validator ` + $positiveStatus $profile ` + (Join-Path $OutputDirectory 'unrelated-same-name.validation.json') ` + $captureReport $true + + $reusedCapture = Join-Path $OutputDirectory 'reused-pid.capture.json' + $capturedIdentity = [string]$captured.processInstanceIdentity + $identitySeparator = $capturedIdentity.LastIndexOf(':') + $capturedStartValue = [uint64]::Parse( + $capturedIdentity.Substring($identitySeparator + 1), + [Globalization.CultureInfo]::InvariantCulture) + $reusedPriorIdentity = $capturedIdentity.Substring(0, $identitySeparator + 1) ` + + ($capturedStartValue + 1).ToString( + [Globalization.CultureInfo]::InvariantCulture) + Write-ProcessCapture ` + $reusedCapture ` + $unrelated.Id ` + $reusedPriorIdentity ` + $sessionConfig ` + ([string]$captured.commandLineFingerprintSha256) + $reusedReport = Join-Path $OutputDirectory 'reused-pid.validation.json' + Invoke-Validator $positiveStatus $profile $reusedReport $reusedCapture $true + $reusedResult = Get-Content -LiteralPath $reusedReport -Raw | ConvertFrom-Json + if (-not $reusedResult.capturedPidReused -or + -not $reusedResult.capturedProcessInstanceExited -or + -not $reusedResult.sessionConfigProcessExited) { + throw 'A reused PID was not distinguished from the exited captured instance.' + } +} +finally { + Set-Content -LiteralPath $targetRelease -Value 'release' -NoNewline + Set-Content -LiteralPath $unrelatedRelease -Value 'release' -NoNewline + if (-not $target.HasExited) { $target.WaitForExit() } + if (-not $unrelated.HasExited) { $unrelated.WaitForExit() } + $target.Dispose() + $unrelated.Dispose() +} + +$summary = [ordered]@{ + schemaVersion = 1 + kind = 'campaign-la-gate-helper-tests' + success = $true + disconnectedReasonNegatives = 3 + credentialStringFieldNegatives = $secretCases.Count + exactPidCapture = $true + stableProcessInstanceCapture = $true + liveProcessInstanceRejected = $true + malformedProcessCaptureRejected = $true + injectedPidReuseIgnored = $true + unrelatedSameNameIgnored = $true + platform = if ($IsWindows) { 'windows' } else { 'linux' } +} +$summary | ConvertTo-Json -Depth 4 | + Set-Content -LiteralPath (Join-Path $OutputDirectory 'summary.json') -Encoding utf8NoBOM +Write-Host "Campaign LA gate helper tests: $OutputDirectory" diff --git a/tools/test-campaign-la-script-safety.ps1 b/tools/test-campaign-la-script-safety.ps1 new file mode 100644 index 00000000..d7f93c90 --- /dev/null +++ b/tools/test-campaign-la-script-safety.ps1 @@ -0,0 +1,378 @@ +<# +.SYNOPSIS + Connection-free negative and determinism tests for Campaign LA scripts. +#> +[CmdletBinding()] +param( + [string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path, + [Parameter(Mandatory = $true)][string]$OutputDirectory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'Campaign LA script-safety tests require PowerShell 7 or newer.' +} +$Repository = [IO.Path]::GetFullPath($Repository) +$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory) +if (Test-Path -LiteralPath $OutputDirectory) { + throw '-OutputDirectory must be fresh.' +} +$null = New-Item -ItemType Directory -Path $OutputDirectory +$pwsh = [Environment]::ProcessPath +if ([string]::IsNullOrWhiteSpace($pwsh)) { + throw 'The PowerShell process path is unavailable.' +} +$preflight = Join-Path $Repository 'tools/run-campaign-la-preflight.ps1' +$fixture = Join-Path $Repository 'tools/new-campaign-la-update-fixture.ps1' +$negativeCount = 0 + +function Invoke-Expected( + [string]$Script, + [string[]]$Arguments, + [bool]$ShouldPass, + [string]$Name) { + $start = [Diagnostics.ProcessStartInfo]::new($pwsh) + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.ArgumentList.Add('-NoProfile') + $start.ArgumentList.Add('-File') + $start.ArgumentList.Add($Script) + foreach ($argument in $Arguments) { $start.ArgumentList.Add($argument) } + $process = [Diagnostics.Process]::Start($start) + if ($null -eq $process) { throw "Could not start safety case '$Name'." } + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $outText = $stdout.GetAwaiter().GetResult() + $errorText = $stderr.GetAwaiter().GetResult() + $exitCode = $process.ExitCode + $process.Dispose() + if (($exitCode -eq 0) -ne $ShouldPass) { + throw "Safety case '$Name' result mismatch (exit $exitCode). $outText $errorText" + } + if (-not $ShouldPass) { $script:negativeCount++ } +} + +$allowed = Join-Path $OutputDirectory 'campaign-la-preflight-safety' +$null = New-Item -ItemType Directory -Path $allowed +Invoke-Expected $preflight @( + '-Repository', $Repository, + '-AllowedOutputRoot', $allowed, + '-OutputDirectory', (Join-Path $allowed 'positive'), + '-DryRun') $true 'preflight-positive' + +$existingEmpty = Join-Path $allowed 'existing-empty' +$null = New-Item -ItemType Directory -Path $existingEmpty +Invoke-Expected $preflight @( + '-Repository', $Repository, + '-AllowedOutputRoot', $allowed, + '-OutputDirectory', $existingEmpty, + '-DryRun') $false 'preflight-existing-empty' + +$existingNonempty = Join-Path $allowed 'existing-nonempty' +$null = New-Item -ItemType Directory -Path $existingNonempty +Set-Content -LiteralPath (Join-Path $existingNonempty 'owner') -Value 'preserve' +Invoke-Expected $preflight @( + '-Repository', $Repository, + '-AllowedOutputRoot', $allowed, + '-OutputDirectory', $existingNonempty, + '-DryRun') $false 'preflight-existing-nonempty' + +$payloadRootRefusal = Join-Path $OutputDirectory 'update-payloads' +$null = New-Item -ItemType Directory -Path $payloadRootRefusal +foreach ($case in @( + [pscustomobject]@{ Name = 'preflight-root'; Allowed = $Repository; Output = (Join-Path $Repository 'blocked') }, + [pscustomobject]@{ Name = 'preflight-home'; Allowed = [Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile); Output = (Join-Path ([Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile)) 'blocked') }, + [pscustomobject]@{ Name = 'preflight-source'; Allowed = (Join-Path $Repository 'src'); Output = (Join-Path $Repository 'src/blocked') }, + [pscustomobject]@{ Name = 'preflight-payload'; Allowed = $payloadRootRefusal; Output = (Join-Path $payloadRootRefusal 'blocked') }, + [pscustomobject]@{ Name = 'preflight-outside'; Allowed = $allowed; Output = (Join-Path $OutputDirectory 'outside') }, + [pscustomobject]@{ Name = 'preflight-allowed-root-itself'; Allowed = $allowed; Output = $allowed })) { + Invoke-Expected $preflight @( + '-Repository', $Repository, + '-AllowedOutputRoot', $case.Allowed, + '-OutputDirectory', $case.Output, + '-DryRun') $false $case.Name +} + +$reparseTarget = Join-Path $OutputDirectory 'campaign-la-reparse-target' +$reparseRoot = Join-Path $OutputDirectory 'campaign-la-reparse-link' +$null = New-Item -ItemType Directory -Path $reparseTarget +if ($IsWindows) { + $null = New-Item -ItemType Junction -Path $reparseRoot -Target $reparseTarget +} +else { + $null = New-Item -ItemType SymbolicLink -Path $reparseRoot -Target $reparseTarget +} +Invoke-Expected $preflight @( + '-Repository', $Repository, + '-AllowedOutputRoot', $reparseRoot, + '-OutputDirectory', (Join-Path $reparseRoot 'blocked'), + '-DryRun') $false 'preflight-reparse-root' + +$source = Join-Path $OutputDirectory 'payload-source' +$null = New-Item -ItemType Directory -Path $source +function Fixture-DryArguments([string]$Destination, [string]$PayloadSource) { + return @( + '-OutputDirectory', $Destination, + '-ClientWinX64DirectoryA', $PayloadSource, + '-LauncherWinX64DirectoryA', $PayloadSource, + '-ClientLinuxX64DirectoryA', $PayloadSource, + '-LauncherLinuxX64DirectoryA', $PayloadSource, + '-ClientWinX64DirectoryB', $PayloadSource, + '-LauncherWinX64DirectoryB', $PayloadSource, + '-ClientLinuxX64DirectoryB', $PayloadSource, + '-LauncherLinuxX64DirectoryB', $PayloadSource, + '-DryRun') +} +Invoke-Expected $fixture (Fixture-DryArguments (Join-Path $source 'child') $source) ` + $false 'fixture-output-inside-source' +Invoke-Expected $fixture (Fixture-DryArguments $source (Join-Path $source 'child-source')) ` + $false 'fixture-source-inside-output' +Invoke-Expected $fixture (Fixture-DryArguments $source $source) ` + $false 'fixture-output-equals-source' +$nearMatch = Join-Path $OutputDirectory 'payload-source-near' +Invoke-Expected $fixture (Fixture-DryArguments $nearMatch $source) ` + $true 'fixture-near-match' + +$sourceLink = Join-Path $OutputDirectory 'payload-source-link' +if ($IsWindows) { + $null = New-Item -ItemType Junction -Path $sourceLink -Target $source +} +else { + $null = New-Item -ItemType SymbolicLink -Path $sourceLink -Target $source +} +Invoke-Expected $fixture ( + Fixture-DryArguments (Join-Path $OutputDirectory 'reparse-source-output') $sourceLink) ` + $false 'fixture-reparse-source' +$outputTarget = Join-Path $OutputDirectory 'fixture-output-target' +$outputLink = Join-Path $OutputDirectory 'fixture-output-link' +$null = New-Item -ItemType Directory -Path $outputTarget +if ($IsWindows) { + $null = New-Item -ItemType Junction -Path $outputLink -Target $outputTarget +} +else { + $null = New-Item -ItemType SymbolicLink -Path $outputLink -Target $outputTarget +} +Invoke-Expected $fixture (Fixture-DryArguments $outputLink $source) ` + $false 'fixture-reparse-output' + +function Write-PayloadFile([string]$Root, [string]$Name, [string]$Content) { + $path = Join-Path $Root $Name + $directory = Split-Path -Parent $path + $null = New-Item -ItemType Directory -Force -Path $directory + [IO.File]::WriteAllText($path, $Content, [Text.UTF8Encoding]::new($false)) +} + +function Get-ZipUInt16([byte[]]$Bytes, [int]$Offset) { + return [int]$Bytes[$Offset] -bor ([int]$Bytes[$Offset + 1] -shl 8) +} +function Get-ZipUInt32([byte[]]$Bytes, [int]$Offset) { + return [uint32]([uint32]$Bytes[$Offset] -bor + ([uint32]$Bytes[$Offset + 1] -shl 8) -bor + ([uint32]$Bytes[$Offset + 2] -shl 16) -bor + ([uint32]$Bytes[$Offset + 3] -shl 24)) +} +function Test-ZipExecutableName([string]$Name) { + return $Name -cin @( + 'AcDream.App', 'acdream-headless', 'acdream-launcher', 'acdream-bake') +} +function Assert-ZipUnixMetadata([string]$Path) { + [byte[]]$bytes = [IO.File]::ReadAllBytes($Path) + $eocd = $bytes.Length - 22 + if ($eocd -lt 0 -or (Get-ZipUInt32 $bytes $eocd) -ne 0x06054b50 -or + (Get-ZipUInt16 $bytes ($eocd + 20)) -ne 0) { + throw "Fixture ZIP end record is invalid: $Path" + } + $entryCount = Get-ZipUInt16 $bytes ($eocd + 10) + $centralSize = Get-ZipUInt32 $bytes ($eocd + 12) + [uint64]$cursor = Get-ZipUInt32 $bytes ($eocd + 16) + $centralEnd = $cursor + $centralSize + if ($centralEnd -ne $eocd) { throw "Fixture ZIP central bounds are invalid: $Path" } + $rawModes = @{} + for ($index = 0; $index -lt $entryCount; $index++) { + if ($cursor + 46 -gt $centralEnd -or + (Get-ZipUInt32 $bytes ([int]$cursor)) -ne 0x02014b50) { + throw "Fixture ZIP central entry is invalid: $Path" + } + if ($bytes[[int]$cursor + 5] -ne 3) { + throw "Fixture ZIP entry origin is not Unix: $Path" + } + $nameLength = Get-ZipUInt16 $bytes ([int]$cursor + 28) + $extraLength = Get-ZipUInt16 $bytes ([int]$cursor + 30) + $commentLength = Get-ZipUInt16 $bytes ([int]$cursor + 32) + $name = [Text.Encoding]::UTF8.GetString( + $bytes, + [int]$cursor + 46, + $nameLength) + $expectedMode = if (Test-ZipExecutableName $name) { 0x81ED } else { 0x81A4 } + $external = Get-ZipUInt32 $bytes ([int]$cursor + 38) + $expectedExternal = [uint32](([uint64]$expectedMode) -shl 16) + if ($external -ne $expectedExternal) { + throw "Fixture ZIP entry '$name' has wrong raw type/mode bits." + } + $rawModes[$name] = $expectedMode + $cursor += 46 + $nameLength + $extraLength + $commentLength + } + if ($cursor -ne $centralEnd) { throw "Fixture ZIP central length is invalid: $Path" } + + Add-Type -AssemblyName System.IO.Compression + $stream = [IO.File]::OpenRead($Path) + try { + $archive = [IO.Compression.ZipArchive]::new( + $stream, + [IO.Compression.ZipArchiveMode]::Read, + $false, + [Text.Encoding]::UTF8) + try { + if ($archive.Entries.Count -ne $rawModes.Count) { + throw "Fixture ZIP entry count changed through ZipArchive: $Path" + } + foreach ($entry in $archive.Entries) { + $mode = ($entry.ExternalAttributes -shr 16) -band 0xffff + if (-not $rawModes.ContainsKey($entry.FullName) -or + $mode -ne $rawModes[$entry.FullName]) { + throw "ZipArchive reports wrong type/mode for '$($entry.FullName)'." + } + } + } + finally { $archive.Dispose() } + } + finally { $stream.Dispose() } +} + +$payloadRoot = Join-Path $OutputDirectory 'deterministic-payloads' +$payloads = [ordered]@{ + ClientWin = Join-Path $payloadRoot 'client-win' + LauncherWin = Join-Path $payloadRoot 'launcher-win' + ClientLinux = Join-Path $payloadRoot 'client-linux' + LauncherLinux = Join-Path $payloadRoot 'launcher-linux' +} +foreach ($directory in $payloads.Values) { + foreach ($entry in @( + @('nested/I.txt', 'I'), @('nested/Z.txt', 'Z'), + @('nested/ä.txt', 'a-umlaut'), @('nested/ı.txt', 'dotless-i'))) { + Write-PayloadFile $directory $entry[0] $entry[1] + } +} +Write-PayloadFile $payloads.ClientWin 'AcDream.App.exe' 'client-win-gui' +Write-PayloadFile $payloads.ClientWin 'acdream-headless.exe' 'client-win-headless' +Write-PayloadFile $payloads.LauncherWin 'acdream-launcher.exe' 'launcher-win' +Write-PayloadFile $payloads.LauncherWin 'acdream-bake.exe' 'bake-win' +Write-PayloadFile $payloads.ClientLinux 'AcDream.App' 'client-linux-gui' +Write-PayloadFile $payloads.ClientLinux 'acdream-headless' 'client-linux-headless' +Write-PayloadFile $payloads.LauncherLinux 'acdream-launcher' 'launcher-linux' +Write-PayloadFile $payloads.LauncherLinux 'acdream-bake' 'bake-linux' + +$fixtureParameters = @{ + ClientWinX64DirectoryA = $payloads.ClientWin + LauncherWinX64DirectoryA = $payloads.LauncherWin + ClientLinuxX64DirectoryA = $payloads.ClientLinux + LauncherLinuxX64DirectoryA = $payloads.LauncherLinux + ClientWinX64DirectoryB = $payloads.ClientWin + LauncherWinX64DirectoryB = $payloads.LauncherWin + ClientLinuxX64DirectoryB = $payloads.ClientLinux + LauncherLinuxX64DirectoryB = $payloads.LauncherLinux +} +$inventories = [Collections.Generic.List[object]]::new() +$originalCulture = [Globalization.CultureInfo]::CurrentCulture +$originalUiCulture = [Globalization.CultureInfo]::CurrentUICulture +try { + foreach ($cultureName in @('en-US', 'tr-TR', 'sv-SE')) { + $culture = [Globalization.CultureInfo]::GetCultureInfo($cultureName) + [Globalization.CultureInfo]::CurrentCulture = $culture + [Globalization.CultureInfo]::CurrentUICulture = $culture + $destination = Join-Path $OutputDirectory "fixture-$cultureName" + & $fixture -OutputDirectory $destination @fixtureParameters + foreach ($zip in @(Get-ChildItem -LiteralPath $destination -Filter '*.zip' -File -Recurse)) { + Assert-ZipUnixMetadata $zip.FullName + } + $relativePaths = [string[]]@(Get-ChildItem -LiteralPath $destination -File -Recurse | + Where-Object { $_.Name -ne 'fixture-report.json' } | + ForEach-Object { + [IO.Path]::GetRelativePath($destination, $_.FullName).Replace('\', '/') + }) + [Array]::Sort($relativePaths, [StringComparer]::Ordinal) + $inventory = @($relativePaths | ForEach-Object { + $path = Join-Path $destination $_.Replace('/', [IO.Path]::DirectorySeparatorChar) + "$_|$((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant())" + }) + $inventories.Add($inventory) + } +} +finally { + [Globalization.CultureInfo]::CurrentCulture = $originalCulture + [Globalization.CultureInfo]::CurrentUICulture = $originalUiCulture +} +$firstInventory = [string]::Join("`n", [string[]]$inventories[0]) +foreach ($inventory in $inventories) { + if ([string]::Join("`n", [string[]]$inventory) -cne $firstInventory) { + throw 'Fixture hashes changed with the current culture.' + } +} +$digestBytes = [Security.Cryptography.SHA256]::HashData( + [Text.Encoding]::UTF8.GetBytes($firstInventory)) +$deterministicDigest = [Convert]::ToHexString($digestBytes).ToLowerInvariant() +$expectedCrossPlatformDigest = + 'cc58d5717de6686690b7f01213c9d52a99aef49447ff645e134f8c97ec8e3a76' +if ($deterministicDigest -cne $expectedCrossPlatformDigest) { + throw "Fixture artifact hashes differ from the pinned Windows/Linux contract: actual $deterministicDigest." +} + +$nativeExtractionModesValidated = $false +if ($IsLinux) { + $unzip = @(Get-Command unzip -CommandType Application -ErrorAction Stop)[0].Source + $extractClient = Join-Path $OutputDirectory 'native-extract-client' + $extractLauncher = Join-Path $OutputDirectory 'native-extract-launcher' + $null = New-Item -ItemType Directory -Path $extractClient + $null = New-Item -ItemType Directory -Path $extractLauncher + & $unzip -qq (Join-Path $OutputDirectory 'fixture-en-US/A/client-linux-x64.zip') ` + -d $extractClient + if ($LASTEXITCODE -ne 0) { throw 'Native client ZIP extraction failed.' } + & $unzip -qq (Join-Path $OutputDirectory 'fixture-en-US/A/launcher-linux-x64.zip') ` + -d $extractLauncher + if ($LASTEXITCODE -ne 0) { throw 'Native launcher ZIP extraction failed.' } + $mode755 = [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite -bor + [IO.UnixFileMode]::UserExecute -bor [IO.UnixFileMode]::GroupRead -bor + [IO.UnixFileMode]::GroupExecute -bor [IO.UnixFileMode]::OtherRead -bor + [IO.UnixFileMode]::OtherExecute + $mode644 = [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite -bor + [IO.UnixFileMode]::GroupRead -bor [IO.UnixFileMode]::OtherRead + foreach ($path in @( + (Join-Path $extractClient 'AcDream.App'), + (Join-Path $extractClient 'acdream-headless'), + (Join-Path $extractLauncher 'acdream-launcher'), + (Join-Path $extractLauncher 'acdream-bake'))) { + if ([IO.File]::GetUnixFileMode($path) -ne $mode755) { + throw "Native extraction did not retain mode 0755: $path" + } + } + foreach ($path in @( + (Join-Path $extractClient 'nested/I.txt'), + (Join-Path $extractClient 'campaign-la-fixture-release.txt'), + (Join-Path $extractLauncher 'nested/Z.txt'), + (Join-Path $extractLauncher 'campaign-la-fixture-release.txt'))) { + if ([IO.File]::GetUnixFileMode($path) -ne $mode644) { + throw "Native extraction did not retain mode 0644: $path" + } + } + $nativeExtractionModesValidated = $true +} + +$summary = [ordered]@{ + schemaVersion = 1 + kind = 'campaign-la-script-safety-tests' + success = $true + negativeCases = $negativeCount + cultures = @('en-US', 'tr-TR', 'sv-SE') + fixtureArtifactSetSha256 = $deterministicDigest + crossPlatformExpectedSha256 = $expectedCrossPlatformDigest + zipOrigin = 'unix' + zipModesValidated = $true + nativeExtractionModesValidated = $nativeExtractionModesValidated +} +$summary | ConvertTo-Json -Depth 5 | + Set-Content -LiteralPath (Join-Path $OutputDirectory 'summary.json') -Encoding utf8NoBOM +Write-Host "Campaign LA script safety tests: $OutputDirectory" diff --git a/tools/test-campaign-la-session-status.ps1 b/tools/test-campaign-la-session-status.ps1 new file mode 100644 index 00000000..ce89fcd2 --- /dev/null +++ b/tools/test-campaign-la-session-status.ps1 @@ -0,0 +1,536 @@ +<# +.SYNOPSIS + Strict Campaign LA v1 session-status and terminal-process validator. + +.DESCRIPTION + Validates exact JSONL property sets and property order, lifecycle order for + probe/guiSelect/gui/headless, terminal semantics, plugin expectations, + credential redaction, and absence of launcher child-process leaks. The + report contains hashes and event names only; it does not copy account, + character, command, plugin-error, or other payload text. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$StatusFile, + [Parameter(Mandatory = $true)] + [ValidateSet('probe', 'guiSelect', 'gui', 'headless')][string]$Mode, + [Parameter(Mandatory = $true)][string]$ProcessCapturePath, + [Parameter(Mandatory = $true)][string]$CredentialProfilePath, + [string]$ExpectedSessionId, + [string[]]$ExpectedPlugin = @(), + [switch]$ExpectNoEnteredWorld, + [switch]$AllowPluginFailure, + [switch]$AllowLoginCommandFailure, + [string]$ReportPath, + [int]$ProcessExitWaitSeconds = 5 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'Campaign LA status validation requires PowerShell 7 or newer.' +} +if ($ExpectNoEnteredWorld -and $Mode -ne 'guiSelect') { + throw '-ExpectNoEnteredWorld is valid only for a guiSelect row.' +} +. (Join-Path $PSScriptRoot 'CampaignLaProcessCorrelation.ps1') +if (-not [IO.Path]::IsPathFullyQualified($StatusFile)) { + $StatusFile = [IO.Path]::GetFullPath($StatusFile) +} +if (-not (Test-Path -LiteralPath $StatusFile -PathType Leaf)) { + throw "Status file does not exist: $StatusFile" +} +if (-not [IO.Path]::IsPathFullyQualified($ProcessCapturePath)) { + throw '-ProcessCapturePath must be absolute.' +} +$ProcessCapturePath = [IO.Path]::GetFullPath($ProcessCapturePath) +if (-not (Test-Path -LiteralPath $ProcessCapturePath -PathType Leaf)) { + throw "Process capture does not exist: $ProcessCapturePath" +} +$captureItem = Get-Item -LiteralPath $ProcessCapturePath -Force +if (($captureItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'Process capture must not be a reparse point.' +} +$captureDocument = [Text.Json.JsonDocument]::Parse( + [IO.File]::ReadAllText($ProcessCapturePath)) +try { + $captureRoot = $captureDocument.RootElement + if ($captureRoot.ValueKind -ne [Text.Json.JsonValueKind]::Object) { + throw 'Process capture root must be an object.' + } + $captureNames = @($captureRoot.EnumerateObject() | ForEach-Object { $_.Name }) + $expectedCaptureNames = @( + 'schemaVersion', 'kind', 'processId', 'processInstanceIdentity', + 'sessionId', 'sessionConfigPath', 'commandLineFingerprintSha256', + 'capturedUtc') + if ([string]::Join("`n", $captureNames) -cne + [string]::Join("`n", $expectedCaptureNames)) { + throw 'Process capture fields/order do not match schema v2.' + } + if ($captureRoot.GetProperty('schemaVersion').GetInt32() -ne 2 -or + $captureRoot.GetProperty('kind').GetString() -cne + 'campaign-la-session-process-capture') { + throw 'Process capture schema/kind is invalid.' + } + $capturedProcessId = $captureRoot.GetProperty('processId').GetInt32() + if ($capturedProcessId -le 0) { throw 'Process capture PID is invalid.' } + $capturedProcessIdentity = $captureRoot.GetProperty( + 'processInstanceIdentity').GetString() + $expectedIdentityPattern = if ($IsWindows) { + '^windows-creation-v1:[0-9]{15,19}$' + } else { '^linux-proc-start-v1:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:[0-9]+$' } + if ($capturedProcessIdentity -notmatch $expectedIdentityPattern) { + throw 'Process capture instance identity is invalid for this platform.' + } + $capturedSessionId = $captureRoot.GetProperty('sessionId').GetString() + if ([string]::IsNullOrWhiteSpace($capturedSessionId) -or + $capturedSessionId.IndexOfAny([IO.Path]::GetInvalidFileNameChars()) -ge 0) { + throw 'Process capture session id is invalid.' + } + $capturedSessionConfigPath = $captureRoot.GetProperty( + 'sessionConfigPath').GetString() + if (-not [IO.Path]::IsPathFullyQualified($capturedSessionConfigPath)) { + throw 'Process capture session-config path is not absolute.' + } + $normalizedCapturedConfigPath = [IO.Path]::GetFullPath( + $capturedSessionConfigPath) + if ($capturedSessionConfigPath -cne $normalizedCapturedConfigPath -or + [IO.Path]::GetFileName($capturedSessionConfigPath) -cne 'session.json' -or + [IO.Path]::GetFileName([IO.Path]::GetDirectoryName( + $capturedSessionConfigPath)) -cne $capturedSessionId) { + throw 'Process capture session-config path is not the exact normalized session path.' + } + $capturedCommandFingerprint = $captureRoot.GetProperty( + 'commandLineFingerprintSha256').GetString() + if ($capturedCommandFingerprint -cnotmatch '^[0-9a-f]{64}$') { + throw 'Process capture command-line fingerprint is invalid.' + } + $capturedUtcText = $captureRoot.GetProperty('capturedUtc').GetString() + $capturedUtc = [DateTimeOffset]::MinValue + if (-not [DateTimeOffset]::TryParseExact( + $capturedUtcText, + 'O', + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::RoundtripKind, + [ref]$capturedUtc) -or $capturedUtc.Offset -ne [TimeSpan]::Zero) { + throw 'Process capture timestamp is not exact UTC round-trip form.' + } +} +finally { $captureDocument.Dispose() } +if (-not [string]::IsNullOrWhiteSpace($ExpectedSessionId) -and + $ExpectedSessionId -cne $capturedSessionId) { + throw 'Process capture session id does not match -ExpectedSessionId.' +} +if (-not [IO.Path]::IsPathFullyQualified($CredentialProfilePath)) { + throw '-CredentialProfilePath must be absolute.' +} +$CredentialProfilePath = [IO.Path]::GetFullPath($CredentialProfilePath) +if (-not (Test-Path -LiteralPath $CredentialProfilePath -PathType Leaf)) { + throw "Credential profile does not exist: $CredentialProfilePath" +} +$credentialItem = Get-Item -LiteralPath $CredentialProfilePath -Force +if (($credentialItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'Credential profile must not be a reparse point.' +} +if ($IsLinux) { + $ownerOnly = [IO.UnixFileMode]::UserRead -bor [IO.UnixFileMode]::UserWrite + if ([IO.File]::GetUnixFileMode($CredentialProfilePath) -ne $ownerOnly) { + throw 'Credential profile must have exact owner-only mode 0600.' + } +} +elseif ($IsWindows) { + $broadSids = @( + 'S-1-1-0', # Everyone + 'S-1-5-11', # Authenticated Users + 'S-1-5-32-545', # Builtin Users + 'S-1-5-32-546') # Guests + $acl = Get-Acl -LiteralPath $CredentialProfilePath + if ($null -eq $acl.Owner) { throw 'Credential profile has no ACL owner.' } + foreach ($rule in $acl.Access) { + if ($rule.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow) { + continue + } + try { + $sid = $rule.IdentityReference.Translate( + [Security.Principal.SecurityIdentifier]).Value + } + catch { $sid = [string]$rule.IdentityReference.Value } + if ($sid -in $broadSids -and $rule.FileSystemRights -ne 0) { + throw 'Credential profile grants access to a broad Windows identity.' + } + } +} +else { throw 'Campaign LA status validation supports Windows and Linux only.' } +if ([string]::IsNullOrWhiteSpace($ReportPath)) { + $ReportPath = "$StatusFile.validation.json" +} +elseif (-not [IO.Path]::IsPathFullyQualified($ReportPath)) { + $ReportPath = [IO.Path]::GetFullPath($ReportPath) +} + +$exactFields = @{ + started = @('v', 'e', 't', 'sessionId') + connected = @('v', 'e', 't', 'sessionId') + characterList = @('v', 'e', 't', 'sessionId', 'accountName', 'slotCount', 'characters') + enteredWorld = @('v', 'e', 't', 'sessionId', 'characterId', 'characterName') + pluginLoaded = @('v', 'e', 't', 'sessionId', 'plugin') + pluginFailed = @('v', 'e', 't', 'sessionId', 'plugin', 'error') + loginCommandFailed = @('v', 'e', 't', 'sessionId', 'commandIndex', 'command', 'error') + disconnected = @('v', 'e', 't', 'sessionId', 'reason') + exited = @('v', 'e', 't', 'sessionId', 'code', 'reason') +} +$failures = [Collections.Generic.List[string]]::new() +$eventNames = [Collections.Generic.List[string]]::new() +$loadedPlugins = [Collections.Generic.List[string]]::new() +$sessionId = $null +$previousTimestamp = [DateTimeOffset]::MinValue +$terminalSeen = $false +$forbiddenValues = [Collections.Generic.HashSet[string]]::new( + [StringComparer]::Ordinal) + +function Add-CredentialValues([Text.Json.JsonElement]$Element) { + if ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Object) { + foreach ($property in $Element.EnumerateObject()) { + if ($property.Name -imatch '^(password|secret)$' -and + $property.Value.ValueKind -eq [Text.Json.JsonValueKind]::String) { + $value = $property.Value.GetString() + if (-not [string]::IsNullOrEmpty($value)) { + $null = $forbiddenValues.Add($value) + } + } + Add-CredentialValues $property.Value + } + } + elseif ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Array) { + foreach ($item in $Element.EnumerateArray()) { Add-CredentialValues $item } + } +} + +$credentialDocument = [Text.Json.JsonDocument]::Parse( + [IO.File]::ReadAllText($CredentialProfilePath)) +try { Add-CredentialValues $credentialDocument.RootElement } +finally { $credentialDocument.Dispose() } +if ($forbiddenValues.Count -eq 0) { + throw 'Credential profile contains no non-empty password/secret value.' +} + +function Test-CredentialEcho([Text.Json.JsonElement]$Element) { + if ($Element.ValueKind -eq [Text.Json.JsonValueKind]::String) { + [string]$text = $Element.GetString() + foreach ($secret in $forbiddenValues) { + if ($text.Contains($secret, [StringComparison]::Ordinal)) { return $true } + } + return $false + } + if ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Object) { + foreach ($property in $Element.EnumerateObject()) { + if (Test-CredentialEcho $property.Value) { return $true } + } + } + elseif ($Element.ValueKind -eq [Text.Json.JsonValueKind]::Array) { + foreach ($item in $Element.EnumerateArray()) { + if (Test-CredentialEcho $item) { return $true } + } + } + return $false +} + +function Get-Properties([Text.Json.JsonElement]$Element) { + $properties = [Collections.Generic.List[object]]::new() + foreach ($property in $Element.EnumerateObject()) { $properties.Add($property) } + return @($properties) +} + +function Assert-String( + [Text.Json.JsonElement]$Root, + [string]$Name, + [bool]$AllowEmpty = $false) { + $value = $Root.GetProperty($Name) + if ($value.ValueKind -ne [Text.Json.JsonValueKind]::String) { + throw "field '$Name' is not a string" + } + $text = $value.GetString() + if (-not $AllowEmpty -and [string]::IsNullOrWhiteSpace($text)) { + throw "field '$Name' is empty" + } + return $text +} + +function Assert-Int32([Text.Json.JsonElement]$Root, [string]$Name) { + $value = $Root.GetProperty($Name) + if ($value.ValueKind -ne [Text.Json.JsonValueKind]::Number) { + throw "field '$Name' is not a number" + } + return $value.GetInt32() +} + +function Assert-UInt32([Text.Json.JsonElement]$Root, [string]$Name) { + $value = $Root.GetProperty($Name) + if ($value.ValueKind -ne [Text.Json.JsonValueKind]::Number) { + throw "field '$Name' is not a number" + } + return $value.GetUInt32() +} + +$lines = @(Get-Content -LiteralPath $StatusFile) +if ($lines.Count -eq 0) { $failures.Add('status stream is empty') } +for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) { + $lineNumber = $lineIndex + 1 + $line = $lines[$lineIndex] + if ([string]::IsNullOrWhiteSpace($line)) { + $failures.Add("line $lineNumber is empty") + continue + } + if ($line -match '(?i)"(?:password|credential|secret|token)"\s*:') { + $failures.Add("line $lineNumber contains a credential-like JSON field") + } + + $document = $null + try { + $document = [Text.Json.JsonDocument]::Parse($line) + $root = $document.RootElement + if ($root.ValueKind -ne [Text.Json.JsonValueKind]::Object) { + throw 'root is not an object' + } + if (Test-CredentialEcho $root) { + throw 'an allowed string field contains an exact credential value' + } + $properties = @(Get-Properties $root) + $names = @($properties | ForEach-Object { $_.Name }) + if (@($names | Sort-Object -Unique).Count -ne $names.Count) { + throw 'object contains duplicate fields' + } + $eventName = Assert-String $root 'e' + if (-not $exactFields.ContainsKey($eventName)) { + throw 'event name is not in the v1 vocabulary' + } + $expected = $exactFields[$eventName] + if ($names.Count -ne $expected.Count -or + [string]::Join("`n", $names) -cne [string]::Join("`n", $expected)) { + throw "event '$eventName' fields/order are '$($names -join ',')'; expected '$($expected -join ',')'" + } + if ((Assert-Int32 $root 'v') -ne 1) { throw 'field v is not 1' } + $timestampText = Assert-String $root 't' + $timestamp = [DateTimeOffset]::MinValue + if (-not [DateTimeOffset]::TryParseExact( + $timestampText, + 'O', + [Globalization.CultureInfo]::InvariantCulture, + [Globalization.DateTimeStyles]::RoundtripKind, + [ref]$timestamp) -or $timestamp.Offset -ne [TimeSpan]::Zero) { + throw 'field t is not an exact UTC round-trip timestamp' + } + if ($timestamp -lt $previousTimestamp) { + throw 'timestamp order moved backwards' + } + $previousTimestamp = $timestamp + $lineSessionId = Assert-String $root 'sessionId' + if ($null -eq $sessionId) { $sessionId = $lineSessionId } + if ($lineSessionId -cne $sessionId) { throw 'sessionId changed within the stream' } + if (-not [string]::IsNullOrWhiteSpace($ExpectedSessionId) -and + $lineSessionId -cne $ExpectedSessionId) { + throw 'sessionId does not match -ExpectedSessionId' + } + if ($terminalSeen) { throw 'an event appears after terminal exited' } + + switch ($eventName) { + 'characterList' { + $null = Assert-String $root 'accountName' $true + $slotCount = Assert-Int32 $root 'slotCount' + if ($slotCount -lt 0) { throw 'slotCount is negative' } + $characters = $root.GetProperty('characters') + if ($characters.ValueKind -ne [Text.Json.JsonValueKind]::Array) { + throw 'characters is not an array' + } + foreach ($character in $characters.EnumerateArray()) { + if ($character.ValueKind -ne [Text.Json.JsonValueKind]::Object) { + throw 'a character is not an object' + } + $characterNames = @((Get-Properties $character) | ForEach-Object { $_.Name }) + $characterExpected = @('id', 'name', 'secondsGreyedOut') + if ([string]::Join("`n", $characterNames) -cne + [string]::Join("`n", $characterExpected)) { + throw 'a character fields/order is not id,name,secondsGreyedOut' + } + $null = Assert-UInt32 $character 'id' + $null = Assert-String $character 'name' + $null = Assert-UInt32 $character 'secondsGreyedOut' + } + } + 'enteredWorld' { + $null = Assert-UInt32 $root 'characterId' + $null = Assert-String $root 'characterName' + } + 'pluginLoaded' { + $loadedPlugins.Add((Assert-String $root 'plugin')) + } + 'pluginFailed' { + $null = Assert-String $root 'plugin' + $null = Assert-String $root 'error' + if (-not $AllowPluginFailure) { throw 'pluginFailed is not allowed for this row' } + } + 'loginCommandFailed' { + if ((Assert-Int32 $root 'commandIndex') -lt 0) { + throw 'commandIndex is negative' + } + $null = Assert-String $root 'command' $true + $null = Assert-String $root 'error' + if (-not $AllowLoginCommandFailure) { + throw 'loginCommandFailed is not allowed for this row' + } + } + 'disconnected' { + $reason = Assert-String $root 'reason' + if ($reason -cne 'stopped') { + throw "terminal disconnected reason is '$reason', expected 'stopped'" + } + } + 'exited' { + $code = Assert-Int32 $root 'code' + $reason = Assert-String $root 'reason' + if ($code -ne 0) { throw "terminal exit code is $code, expected 0" } + $expectedReason = if ($Mode -eq 'probe') { 'probe' } else { 'graceful' } + if ($reason -cne $expectedReason) { + throw "terminal reason does not match mode '$Mode'" + } + $terminalSeen = $true + } + } + $eventNames.Add($eventName) + } + catch { + $failures.Add("line ${lineNumber}: $($_.Exception.Message)") + } + finally { if ($null -ne $document) { $document.Dispose() } } +} + +function Require-Count([string]$EventName, [int]$Count) { + $actual = @($eventNames | Where-Object { $_ -ceq $EventName }).Count + if ($actual -ne $Count) { + $failures.Add("event '$EventName' count is $actual, expected $Count") + } +} +function First-Index([string]$EventName) { + for ($index = 0; $index -lt $eventNames.Count; $index++) { + if ($eventNames[$index] -ceq $EventName) { return $index } + } + return -1 +} + +Require-Count 'started' 1 +Require-Count 'connected' 1 +Require-Count 'characterList' 1 +Require-Count 'disconnected' 1 +Require-Count 'exited' 1 +$expectEnteredWorld = $Mode -ne 'probe' -and -not $ExpectNoEnteredWorld +Require-Count 'enteredWorld' $(if ($expectEnteredWorld) { 1 } else { 0 }) +if ($eventNames.Count -gt 0 -and $eventNames[0] -cne 'started') { + $failures.Add('started is not the first event') +} +if ($eventNames.Count -gt 0 -and $eventNames[-1] -cne 'exited') { + $failures.Add('exited is not the final event') +} +$orderedRequired = if (-not $expectEnteredWorld) { + @('started', 'connected', 'characterList', 'disconnected', 'exited') +} else { + @('started', 'connected', 'characterList', 'enteredWorld', 'disconnected', 'exited') +} +$last = -1 +foreach ($name in $orderedRequired) { + $next = First-Index $name + if ($next -ge 0 -and $next -le $last) { + $failures.Add("event '$name' is out of lifecycle order") + } + $last = $next +} +$connectedIndex = First-Index 'connected' +foreach ($index in 0..([Math]::Max(0, $eventNames.Count - 1))) { + if ($eventNames.Count -eq 0) { break } + if ($eventNames[$index] -in @('pluginLoaded', 'pluginFailed') -and + ($index -le 0 -or $index -ge $connectedIndex)) { + $failures.Add("plugin event at index $index is outside started-to-connected startup") + } +} +foreach ($plugin in $ExpectedPlugin) { + if (-not ($loadedPlugins -ccontains $plugin)) { + $failures.Add("expected plugin '$plugin' did not emit pluginLoaded") + } +} +$expectedPluginSet = @($ExpectedPlugin | Sort-Object -Unique) +$loadedPluginSet = @($loadedPlugins | Sort-Object -Unique) +if ($loadedPlugins.Count -ne $loadedPluginSet.Count) { + $failures.Add('a plugin emitted pluginLoaded more than once') +} +if ([string]::Join("`n", $loadedPluginSet) -cne + [string]::Join("`n", $expectedPluginSet)) { + $failures.Add( + "loaded plugin set has $($loadedPluginSet.Count) member(s), expected $($expectedPluginSet.Count)") +} +$enteredWorldIndex = First-Index 'enteredWorld' +for ($index = 0; $index -lt $eventNames.Count; $index++) { + if ($eventNames[$index] -ceq 'loginCommandFailed' -and + ($enteredWorldIndex -lt 0 -or $index -le $enteredWorldIndex)) { + $failures.Add("loginCommandFailed at index $index did not follow enteredWorld") + } +} + +$deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds) +$capturedState = $null +do { + $currentProcessIdentity = Get-CampaignLaProcessInstanceIdentity ` + -ProcessId $capturedProcessId + $correlations = @(Get-CampaignLaSessionProcessCorrelations) + $capturedState = Test-CampaignLaCapturedProcessState ` + -ProcessId $capturedProcessId ` + -ProcessInstanceIdentity $capturedProcessIdentity ` + -SessionConfigPath $capturedSessionConfigPath ` + -CurrentProcessInstanceIdentity $currentProcessIdentity ` + -Correlations $correlations + if (-not $capturedState.SameInstanceAlive -and + -not $capturedState.ExactConfigPathAlive) { + break + } + Start-Sleep -Milliseconds 100 +} while ([DateTime]::UtcNow -lt $deadline) +if ($capturedState.SameInstanceAlive) { + $failures.Add( + "captured launcher child process instance PID $capturedProcessId remains alive") +} +if ($capturedState.ExactConfigPathAlive) { + $failures.Add('a launcher child remains correlated to the exact session-config path') +} + +$reportDirectory = Split-Path -Parent $ReportPath +if (-not [string]::IsNullOrEmpty($reportDirectory)) { + $null = New-Item -ItemType Directory -Force -Path $reportDirectory +} +$report = [ordered]@{ + schemaVersion = 1 + kind = 'campaign-la-session-status-validation' + success = ($failures.Count -eq 0) + mode = $Mode + enteredWorldExpected = $expectEnteredWorld + statusFile = [IO.Path]::GetFileName($StatusFile) + statusSize = (Get-Item -LiteralPath $StatusFile).Length + statusSha256 = (Get-FileHash -LiteralPath $StatusFile -Algorithm SHA256).Hash.ToLowerInvariant() + lineCount = $lines.Count + eventNames = @($eventNames) + loadedPluginCount = $loadedPlugins.Count + terminalObserved = $terminalSeen + capturedProcessId = $capturedProcessId + capturedProcessInstanceExited = (-not $capturedState.SameInstanceAlive) + capturedPidReused = [bool]$capturedState.PidReused + sessionConfigCorrelationChecked = $true + sessionConfigProcessExited = (-not $capturedState.ExactConfigPathAlive) + processCaptureSha256 = (Get-FileHash -LiteralPath $ProcessCapturePath -Algorithm SHA256).Hash.ToLowerInvariant() + credentialPermissionsValidated = $true + forbiddenCredentialValueCount = $forbiddenValues.Count + failures = @($failures) + validatedUtc = [DateTime]::UtcNow.ToString('O') +} +$report | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $ReportPath -Encoding utf8NoBOM +Write-Host "Campaign LA status validation report: $ReportPath" +if (-not $report.success) { + $failures | ForEach-Object { Write-Error $_ } + exit 1 +}