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.ReleaseOwner → CompositeTextureArrayCache.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
+ /// 0x100002ed — gmCGProfessionPage::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