Merge Campaign LA + Campaign CC: the acdream launcher and retail character creation, both CLOSED user-accepted
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / portable-launcher (ubuntu-latest) (push) Waiting to run
Headless portability / portable-launcher (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run

Campaign LA (2026-08-14/15): Avalonia launcher/installer/updater
(Windows+Linux), retail character-management screen, session-config +
status-stream contract (§LA1), two connected gate rounds USER-PASSED.

Campaign CC (2026-08-15/16): the full retail character-creation flow —
chargen data layer, byte-exact 0xF656 + complete 0xF643 handling,
RuntimeCharacterCreationState, the six-page gmCharGenMainUI screen with
live 3D preview and the real color wheel, RandomizeCharacter open-roll,
launcher payload cycle. Seven slices review-closed; the connected gate's
extended round (GF-1..16, R2/R3/R4 re-tests) PASSED 2026-08-16 on build
1.0.2-cc.o. Milestone: the first live character created by acdream
against ACE.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-16 19:14:30 +02:00
commit d3755eb231
419 changed files with 88143 additions and 937 deletions

View file

@ -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

View file

@ -7,6 +7,9 @@
<Project Path="src/AcDream.Core/AcDream.Core.csproj" />
<Project Path="src/AcDream.Core.Net/AcDream.Core.Net.csproj" />
<Project Path="src/AcDream.Headless/AcDream.Headless.csproj" />
<Project Path="src/AcDream.Launcher/AcDream.Launcher.csproj" />
<Project Path="src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj" />
<Project Path="src/AcDream.Platform/AcDream.Platform.csproj" />
<Project Path="src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj" />
<Project Path="src/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj" />
<Project Path="src/AcDream.Runtime/AcDream.Runtime.csproj" />
@ -24,6 +27,13 @@
<Project Path="tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj" />
<Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" />
<Project Path="tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj" />
<Project Path="tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj" />
<Project Path="tests/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild/AcDream.Launcher.Core.Tests.Fixtures.ConsoleSignalChild.csproj" />
<Project Path="tests/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent/AcDream.Launcher.Core.Tests.Fixtures.ConsolelessSupervisorParent.csproj" />
<Project Path="tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj" />
<Project Path="tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj" />
<Project Path="tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj" />
<Project Path="tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/AcDream.Plugin.Tests.Fixtures.HostPlugin.csproj" />
<Project Path="tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj" />
<Project Path="tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj" />
</Folder>

View file

@ -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` roster<slots ghost gate)
`gmCharGenMainUI`'s six-page flow (Heritage / Profession / Skills /
Appearance with live 3D preview / Town / Summary with its own zoomed-out
viewport) → byte-exact 0xF656 with the 55-slot invariant → complete
0xF643 handling (roster append + retail log-straight-in; every rejection
dialog, incl. the corrected ground truth that retail shows NameDBDown
for Pending/Undef — the plan's original "retail swallows it" was
DISPROVEN at CC5's review) → the §LA1 `characterCreated`/`creationFailed`
launcher status cycle. `RandomizeCharacter` + sub-primitives are ported
(retail's ctor-time open-roll incl. the gender-flip quirk; humans-only
random heritage ids 1-4 — a real retail quirk). Plan + ledger:
`docs/plans/2026-08-15-character-creation-campaign.md`; connected gate
script: `docs/research/2026-08-16-campaign-cc-test-script.md` (launch:
launcher flow, or `ACDREAM_RETAIL_UI=1` + `ACDREAM_OPEN_CHARGEN=1`);
START at `claude-memory/project_character_creation_campaign_handoff.md`.
Register churn: AP-214/AP-225/TS-82/AD-101 retired; AP-211 updated;
AP-212 narrowed; AP-215AP-229 filed (AP-221 one-shot preview binding,
AP-222 spin-highlight no-op, AP-229 stacked-screens-vs-retail-teardown
are the ones a gate tester will meet). Known-flake set now also names
`RuntimeCollisionReportingStateTests.WarmedSteadyContactRefreshDoesNotAllocate`
(full-solution parallel load only). Suites at `2176ba76`: full solution
14,426 / 4 skips, App 5257/3, Runtime 1735/0, Launcher.Core 324/0.
**Placement cutover — C4 COMPLETE 2026-08-05, merged to main.** Every
placement route now runs through the canonical residence + continuation-
executor owner. Routes landed this session: 4b-3 remote teleport/cell-less

View file

@ -24,6 +24,596 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
## #410 — Client-wide VJustify (vertical text justification) enum mapping + unauthored default are wrong (retail default is Top, not Center)
**Status:** OPEN
**Severity:** MEDIUM (silently mispositions every DAT-imported `UiText` that
relies on the unauthored default, or that authors a raw vertical-
justification value other than 1 — currently invisible unless two
elements' boxes are close/overlapping the way the Skills info-box panes
are, but could affect vertical alignment anywhere client-wide)
Found during Campaign CC gate round 1 re-test 2's R3-3 investigation
(`docs/research/2026-08-16-campaign-cc-gate-round1-findings.md`). The
Skills page's info-box title (`0x100003fb`) and description (`0x100003fc`)
panes author NO dat property `0x15` (vertical justification) — live-DAT-
probe-confirmed absent on both — so both fall to whatever this port's
unauthored default resolves to, currently `VJustify.Center`
(`ElementReader.cs`'s `VJustify` field default and
`ElementReader.cs`/`DatWidgetFactory.cs`'s import-time mapping switches).
Byte-traced against retail:
- `UIElement_Text::UIElement_Text` (ctor) `@0x004685ff`: unconditionally
sets `this->m_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<SkillTable>(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<App>()`
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 `<DataDirectory>/crash-reports/launcher-crash-<utc>.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

View file

@ -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
`.<pak>.acdream-bake.<guid:N>.tmp` files are
transaction-owned crash residue
Updates/ -> pinned GitHub manifest + strict SemVer/RID
authority, bounded verified streaming download,
hardened ZIP extraction, immutable
`app/<version>/` 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

View file

@ -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`). |

File diff suppressed because one or more lines are too long

View file

@ -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

View file

@ -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 34 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 LA5LA8 (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 <path>`:** 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 <dats> --out <DataDirectory>/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/<version>/`, 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/<version>/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 `<version>/` 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/<transactionId>/` 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": "<absolute current launcher directory>",
"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-<transactionId>/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 directory>/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 §AI
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
§AI 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; F1F8 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 LA0LA10 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 AI 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. |

File diff suppressed because one or more lines are too long

View file

@ -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 <worktree> 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 <path>` 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 34 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.
```

View file

@ -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 AI 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.
- `<ABSOLUTE_REPOSITORY_ROOT>`: a clean Campaign LA worktree at the exact commit
under test.
- `<ABSOLUTE_RETAIL_DAT_DIRECTORY>`: a read-only source containing
`client_portal.dat`, `client_cell_1.dat`, `client_highres.dat`, and
`client_local_English.dat`.
- `<ACE_PORT>`, `<LA11_SERVER>`, and `<LA11_ACCOUNT>`: 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.
- `<OBSERVER_CHARACTER>`: a second user-controlled character that can observe a
private `/tell` from each play mode.
- `<DISPOSABLE_CHARACTER>`: 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 AH. 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('<ABSOLUTE_REPOSITORY_ROOT>')
$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 | 515 min |
| Windows | complete Release solution test, serial | exit 0; ordinary known skips only | 2060 min |
| Windows | focused Launcher.Core update tests and launcher update/startup-option tests | exit 0 | 14 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 | 1025 min |
| Windows | self-contained single-file launcher publish for `win-x64` and `linux-x64` | launcher + bake roots present, no root DLL fallback | 310 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 | 3590 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 '<ABSOLUTE_RETAIL_DAT_DIRECTORY>'
```
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 '<ROW>-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 '<probe|guiSelect|gui|headless>' `
-ProcessCapturePath $CapturePath `
-CredentialProfilePath (Join-Path $WinConfig 'launcher-profiles.json') `
-ExpectedSessionId $Capture.sessionId `
-ReportPath (Join-Path $Evidence '<ROW>-status.validation.json')
```
Add `-ExpectedPlugin acdream.smoke` to rows DF. 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 AH
### 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 `<ABSOLUTE_RETAIL_DAT_DIRECTORY>` 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 30180 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: 45200 minutes including bake.
### B — server/account CRUD entirely through the UI
1. Add `<LA11_TEMP_SERVER>` at `127.0.0.1:<UNUSED_LOCAL_PORT>`, edit its name
and port, then remove it. Confirm Cancel/Escape makes no mutation.
2. Add `<LA11_SERVER>` at `127.0.0.1:<ACE_PORT>`.
3. Under it add `<LA11_TEMP_ACCOUNT>` 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 `<LA11_ACCOUNT>` 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: 1015 minutes.
### C — live character probe twice, no stale ACE session
1. Select `<LA11_ACCOUNT>`, 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 13 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: 510 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 <OBSERVER_CHARACTER>, LA11-D-<UNIQUE_NONSECRET_NONCE>`. 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: 510 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-<UNIQUE_NONSECRET_NONCE>`.
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: 510 minutes.
### F — headless, plugin/login command, and connected #397 acceptance
1. Change the same character to `headless`, retain `acdream.smoke`, and use
`LA11-F-<UNIQUE_NONSECRET_NONCE>`.
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: 510 minutes.
### G — disposable delete and restore
1. Launch `guiSelect` for `<DISPOSABLE_CHARACTER>`. 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/<SESSION_ID>/status.jsonl') `
-Mode guiSelect `
-ProcessCapturePath (Join-Path $Evidence 'G-process.capture.json') `
-CredentialProfilePath (Join-Path $WinConfig 'launcher-profiles.json') `
-ExpectNoEnteredWorld `
-ExpectedSessionId '<SESSION_ID>' `
-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: 510 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: 1530 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('<ABSOLUTE_LINUX_REPOSITORY_PATH>')
$FixtureLinux = [IO.Path]::GetFullPath('<ABSOLUTE_LINUX_FIXTURE_PATH>')
$PayloadsLinux = [IO.Path]::GetFullPath('<ABSOLUTE_LINUX_PAYLOADS_PATH>')
$LinuxGate = [IO.Path]::GetFullPath('<NEW_ABSOLUTE_LINUX_GATE_ROOT>')
$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 `<ABSOLUTE_LINUX_RETAIL_DAT_DIRECTORY>`;
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 <OBSERVER_CHARACTER>, LA11-I-<UNIQUE_NONSECRET_NONCE>`, 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: 60220 minutes, dominated by the real bake.
## 7. Evidence, redaction, verdict, and cleanup
Expected evidence tree:
```text
logs/campaign-la-user-gate-<timestamp>/
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
AI 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 `<DISPOSABLE_CHARACTER>` 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: 37 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.

View file

@ -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.

View file

@ -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.

File diff suppressed because it is too large Load diff

View file

@ -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-<yourinitials>-<n>`
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.

View file

@ -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 <path>` 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/<id>/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 <path>` (existing CLI).
- GUI: `AcDream.App --session-config <path>` (new; parsed once in
`Program.cs` into `RuntimeOptions` per code-structure rule 4).
- Probe: `AcDream.Headless --config <path>` 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/<id>/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/<version>/`; 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.

View file

@ -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

View file

@ -58,6 +58,11 @@
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
<ProjectReference Include="..\AcDream.Core.Net\AcDream.Core.Net.csproj" />
<ProjectReference Include="..\AcDream.Content\AcDream.Content.csproj" />
<!-- Campaign LA LA0 review finding 6: App consumes AcDream.Platform
types directly (GraphicalHostPlatformServices, Program, GameWindow),
so the reference is declared explicitly per this file's convention
rather than ridden transitively through Runtime. -->
<ProjectReference Include="..\AcDream.Platform\AcDream.Platform.csproj" />
<ProjectReference Include="..\AcDream.UI.Abstractions\AcDream.UI.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>

View file

@ -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<string> Error)
{
public RuntimeCharacterState Character => Runtime.CharacterOwner;
/// <summary>Campaign CC slice CC4: the character-creation options
/// install target — see <c>ContentEffectsAudioCompositionPhase.Compose</c>'s
/// <c>ChargenOptionsInstalled</c> step and
/// <see cref="RuntimeCharacterCreationState.InstallOptions"/>'s own doc
/// for why this is safe at composition time (strictly before any
/// session's <c>Begin</c>).</summary>
public LiveSessionController Session => Runtime.Session;
}
internal interface IGameWindowContentEffectsAudioPublication
@ -96,6 +106,13 @@ internal interface IContentEffectsAudioCompositionFactory
RuntimeCharacterState character,
MagicCatalog catalog);
int GetSpellCount(MagicCatalog catalog);
/// <summary>Campaign CC slice CC4: mirrors the
/// <see cref="LoadMagicCatalog"/>/<see cref="InstallSpellMetadata"/>
/// pair's "load off dats, install once onto the owning Runtime state"
/// shape for the chargen options
/// (<c>AcDream.Content.CharGen.ChargenTableReader.Load</c>).</summary>
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,

View file

@ -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,

View file

@ -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<SkillTable>(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);

View file

@ -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
}
}
/// <summary>
/// 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 <see langword="null"/>,
/// while <c>CurrentGameRuntimeAdapter</c> keeps an already-borrowed reference
/// inert if disposal races the render-thread consumer.
/// </summary>
public IRuntimeCharacterSelectionView? CharacterSelection
{
get
{
lock (_gate)
return !_deactivated && _view is not null
? _view.CharacterSelection
: null;
}
}
/// <summary>Campaign CC slice CC4: same late-bound borrow shape as
/// <see cref="CharacterSelection"/>.</summary>
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

View file

@ -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 <c>StartAnimation</c>'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();

View file

@ -82,7 +82,11 @@ internal sealed record SessionPlayerDependencies(
CombatAttackOperationsSlot CombatAttackOperations,
CombatFeedbackSlot CombatFeedback,
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
Action<string> Log)
Action<string> Log,
/// <summary>Campaign LA slice LA1: the shared per-session status-event
/// writer, no-op when <see cref="RuntimeOptions.StatusFilePath"/> was
/// not configured.</summary>
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

View file

@ -1,4 +1,5 @@
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.Composition;

View file

@ -0,0 +1,73 @@
namespace AcDream.App.Configuration;
/// <summary>
/// Campaign LA slice LA1 review fix (F5): extracted from <c>Program.cs</c>'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 <c>Program</c> class with
/// no stable surface a test assembly can reach.
/// </summary>
internal static class SessionConfigArgumentParsing
{
/// <summary>
/// Finds <paramref name="flag"/> in <paramref name="arguments"/> and
/// returns its value. Three distinct outcomes, distinguished by
/// <paramref name="present"/> and the return value together:
/// <list type="bullet">
/// <item>flag absent: <paramref name="present"/> = <see langword="false"/>,
/// returns <see langword="null"/> — the caller's env-var/positional
/// fallback stays in effect, unchanged from before this flag
/// existed.</item>
/// <item>flag present with a following value: <paramref name="present"/>
/// = <see langword="true"/>, returns that value.</item>
/// <item>flag present but is the LAST argument, with nothing after it:
/// <paramref name="present"/> = <see langword="true"/>, returns
/// <see langword="null"/> — 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.</item>
/// </list>
/// </summary>
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;
}
/// <summary>Returns <paramref name="arguments"/> with <paramref name="flag"/>
/// 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
/// <see cref="ExtractFlagValue"/>'s <c>present</c> output for that).</summary>
internal static string[] WithoutFlagAndValue(string[] arguments, string flag)
{
ArgumentNullException.ThrowIfNull(arguments);
ArgumentException.ThrowIfNullOrWhiteSpace(flag);
var result = new List<string>(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];
}
}

View file

@ -0,0 +1,175 @@
using System.Text.Json.Serialization;
namespace AcDream.App.Configuration;
/// <summary>
/// Campaign LA slice LA1: the graphical host's reader for the pinned
/// session-config document shape shared with
/// <c>AcDream.Headless.Configuration.HeadlessConfiguration</c> — see
/// <c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1 and
/// <c>docs/superpowers/specs/2026-08-14-launcher-campaign-design.md</c> §6.
///
/// <para>
/// This is a DELIBERATELY independent DTO set, not a shared type reused from
/// <c>AcDream.Headless</c> — Headless's config types are internal, tied to
/// its own OP7 <c>characterOptions</c> 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
/// (<c>SessionConfigurationSharedFixtureTests</c> /
/// <c>HeadlessConfigurationSharedFixtureTests</c>).
/// </para>
///
/// <para>
/// Differences from the Headless reader, all intentional per the pinned
/// contract: <see cref="SessionDescriptor.Character"/> is OPTIONAL here
/// (absent = today's first-available fallback; the character-select screen
/// is LA7, not this slice); <see cref="SessionDescriptor.Policy"/> is parsed
/// but never consulted (App has no bot-policy concept); exactly ONE session
/// is required, not "one or more".
/// </para>
/// </summary>
internal sealed class SessionConfiguration
{
[JsonRequired]
public int Version { get; init; }
public SessionProcessSettings? Process { get; init; }
[JsonRequired]
public List<SessionDescriptor?> Sessions { get; init; } = [];
}
internal sealed class SessionProcessSettings
{
public SessionContentDescriptor? Content { get; init; }
/// <summary>Campaign LA slice LA1 review fix (F2): accepted so the SAME
/// document also satisfies the Headless loader's own
/// <c>process.paths</c> member (<c>HeadlessPathOverrides</c>) — parsed
/// and ignored here, exactly like <see cref="SessionDescriptor.Policy"/>
/// and <see cref="SessionDescriptor.CharacterOptions"/> below. App has
/// no config/data/cache directory override concept of its own (those
/// come from <c>ApplicationPathSet</c>/env vars on this host); only the
/// Headless host consumes overrides composed under this key.</summary>
public SessionProcessPathOverrides? Paths { get; init; }
}
/// <summary>Accepted-but-ignored mirror of Headless's
/// <c>HeadlessPathOverrides</c> shape — see
/// <see cref="SessionProcessSettings.Paths"/>.</summary>
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;
/// <summary>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".</summary>
public SessionCharacterSelectorDescriptor? Character { get; init; }
/// <summary>Accepted so the SAME document also satisfies the Headless
/// loader's <c>JsonRequired</c> policy field — parsed and ignored here;
/// App has no bot-policy concept.</summary>
public SessionPolicyDescriptor? Policy { get; init; }
/// <summary>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. <c>"probe"</c> (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 <see cref="System.Text.Json.JsonException"/>. 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.</summary>
public string? Mode { get; init; }
[JsonRequired]
public SessionCredentialDescriptor Credential { get; init; } = new();
/// <summary>Accepted-but-ignored by App; Headless's own loader owns the
/// allow-list semantics for this field (OP7 D8).</summary>
public Dictionary<string, bool>? CharacterOptions { get; init; }
/// <summary>LA1/LA5: plugin ids to load. Absent = load all; explicit
/// empty = load none.</summary>
public List<string>? Plugins { get; init; }
/// <summary>LA1/LA6: ordered chat-typed strings run through the shared
/// Runtime parser/router after entering world.</summary>
public List<string>? LoginCommands { get; init; }
/// <summary>LA1: inter-command delay for <see cref="LoginCommands"/>,
/// milliseconds. Matches the pinned contract default of 500 ms.</summary>
public int LoginCommandDelayMs { get; init; } = 500;
/// <summary>LA1: absolute path for the status-event JSONL stream.
/// Absent = no writer constructed.</summary>
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; }
}
/// <summary>Loose by design: App never inspects the policy's shape beyond
/// "does this document parse" — <c>Id</c>/<c>Role</c> stay untyped strings so
/// this DTO never has to track Headless's own policy-id/role vocabulary.</summary>
internal sealed class SessionPolicyDescriptor
{
public string? Id { get; init; }
public string? Role { get; init; }
}
[JsonConverter(typeof(JsonStringEnumConverter<SessionCredentialProviderKind>))]
internal enum SessionCredentialProviderKind
{
Environment,
StandardInput,
File,
}
internal sealed class SessionCredentialDescriptor
{
[JsonRequired]
public SessionCredentialProviderKind Provider { get; init; }
[JsonRequired]
public string Reference { get; init; } = string.Empty;
}

View file

@ -0,0 +1,18 @@
namespace AcDream.App.Configuration;
/// <summary>Mirrors <c>AcDream.Headless.Configuration.HeadlessConfigurationException</c>
/// — a semantic validation failure of an already well-typed session-config
/// document (a type-SHAPE violation fails earlier, as a raw
/// <see cref="System.Text.Json.JsonException"/> during deserialization).</summary>
internal sealed class SessionConfigurationException : Exception
{
internal SessionConfigurationException(string message)
: base(message)
{
}
internal SessionConfigurationException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View file

@ -0,0 +1,184 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AcDream.App.Configuration;
/// <summary>
/// Campaign LA slice LA1: loads and validates the <c>--session-config</c>
/// document for the graphical host. Same strictness as
/// <c>AcDream.Headless.Configuration.HeadlessConfigurationLoader</c>
/// (camelCase, <see cref="JsonUnmappedMemberHandling.Disallow"/>, camelCase
/// string enums) — see that type's own doc for why the two readers are
/// independent DTOs rather than a shared type.
/// </summary>
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),
},
};
/// <summary>Loads the document and returns the exact one configured
/// <see cref="SessionDescriptor"/> the graphical host runs — the
/// document itself may only ever declare exactly one session.</summary>
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<SessionConfiguration>(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);
}
/// <summary>
/// Campaign LA slice LA1 review fix (F2): <c>mode</c> 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); <c>"probe"</c> gets a specific, actionable
/// message instead of a cryptic unmapped-member JSON error; anything
/// else is a plain configuration error.
/// </summary>
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}'.");
}
}

View file

@ -0,0 +1,156 @@
using AcDream.App.Configuration;
using AcDream.App.Platform;
namespace AcDream.App.Credentials;
/// <summary>
/// Campaign LA slice LA1: resolves a <c>--session-config</c> session's
/// credential reference — the App-side mirror of
/// <c>AcDream.Headless.Credentials.HeadlessCredentialResolver</c> (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:
/// <c>environment</c> (read an env var), <c>standardInput</c> (read one line
/// from stdin, mirroring <c>HeadlessCredentialResolver.ResolveStandardInput</c>),
/// and <c>file</c> (read a credential file relative to a base directory,
/// rejecting symlinks and, on Linux, group/other-readable permissions).
/// </summary>
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;
/// <summary>
/// <paramref name="isLinux"/> is the caller-supplied platform-policy
/// value from <c>GraphicalHostPlatformServices</c>. This file still uses
/// <c>RuntimePlatformGuard.IsLinuxRuntime</c> below as the narrow
/// CA1416-recognized runtime guard required before calling
/// <c>File.GetUnixFileMode</c>; it does not independently select the host
/// platform or bypass the platform-services owner.
/// </summary>
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;
}
}

View file

@ -0,0 +1,65 @@
using System.Security.Cryptography;
namespace AcDream.App.Credentials;
/// <summary>
/// Campaign LA slice LA1: retains a resolved <c>--session-config</c>
/// credential in erasable memory — the App-side mirror of
/// <c>AcDream.Headless.Credentials.HeadlessCredentialSecret</c> (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.
/// </summary>
internal sealed class AppCredentialSecret : IDisposable
{
private char[]? _buffer;
internal AppCredentialSecret(string referenceId, ReadOnlySpan<char> 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)
{
}
}

View file

@ -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;

View file

@ -12,5 +12,5 @@ internal interface ILiveWorldSessionSource
internal interface ILiveUiSessionTarget : ILiveInWorldSource, ILiveWorldSessionSource
{
AcDream.UI.Abstractions.ICommandBus Commands { get; }
AcDream.Runtime.Chat.ICommandBus Commands { get; }
}

View file

@ -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<ExecuteClientCommandCmd>(clientCommands.Execute);
commands.Register<SendServerCommandCmd>(command =>
{
if (!string.IsNullOrEmpty(command.Text))
SendIfActive(() => bindings.SendTalk(command.Text));
});
commands.Register<SendChatCmd>(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<SendRawChannelCmd>(
command => SendIfActive(() => bindings.SendChannel(command.ChannelId, command.Text)));
commands.Register<AddShortcutRuntimeCmd>(
command => SendIfActive(() => bindings.AddShortcut(command.Entry)));
commands.Register<RemoveShortcutRuntimeCmd>(
@ -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)
{
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();
}
/// <summary>
/// The seven <see cref="ChatChannelKind"/> values that ride Turbine
/// (0xF7DE), mapped to the lighter <see cref="ChatChannelKindLite"/>
/// <see cref="TurbineChatMembershipGate"/> reads. Every OTHER channel
/// kind (Fellowship/Vassals/Patron/Monarch/CoVassals/AllegianceBroadcast)
/// is legacy-only (0x0147) — those pipelines never overlap Turbine.
/// <see cref="ChatChannelKind.Allegiance"/> is the one exception, and
/// <see cref="RouteChat"/> 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 <c>/a</c> is bound to the
/// LEGACY <c>AllegianceBroadcast</c> bitflag by default and is only
/// rebound to <c>DoTurbineChat_Allegiance</c> once
/// <c>StartupTurbineChatSystem</c> successfully starts Turbine chat
/// (research doc §4.3). So "Turbine never started" (<c>TurbineChat.
/// Enabled == false</c>) still falls back to legacy, while "Turbine is
/// up but this character has no allegiance room" (<c>Enabled == true</c>,
/// <c>AllegianceRoom == 0</c>) correctly keeps retail's local
/// "Turbine chat is not available." refusal at the membership gate.
/// </summary>
private static readonly Dictionary<ChatChannelKind, ChatChannelKindLite> 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);
}
/// <summary>
/// Step 2 of the CH3 fix list: retail
/// <c>ClientCommunicationSystem::SendTurbineChat @0x0057db10</c>'s local
/// membership gate, raised through the same
/// <c>RuntimeCommunicationState.AddText</c> chokepoint CH2 built for
/// every other client-raised refusal.
/// </summary>
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()),

View file

@ -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<string> _log;
private readonly LiveMovementStatsApplier _movementStats;
private readonly SessionStatusWriter _statusWriter;
private readonly string _sessionId;
private readonly IReadOnlyList<string> _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<string> log)
Action<string> log,
SessionStatusWriter? statusWriter = null,
string sessionId = "app",
IReadOnlyList<string>? 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);
}

View file

@ -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;
}
/// <summary>
/// Campaign CC CC5 review fix round, F3 (2026-08-16). Ports
/// <c>CharGenState::GetSkillScore @ 0x005C4B50</c>'s FULL behavior, not
/// just the shared <see cref="TryCalculate"/> base: after the formula
/// result, retail adds a level-based bonus keyed off the skill's CURRENT
/// advancement class (<c>edi_1</c> in the decomp) — <c>edi_1 == 2</c>
/// (Trained) → <c>result += 5</c>; <c>edi_1 == 3</c> (Specialized) →
/// <c>result += 10</c> — before returning. The decomp's own gate,
/// <c>if (edi_1 &gt;= var_38)</c> (<c>var_38</c> resolves to
/// <c>SkillBase.MinLevel</c> — a decompiler-mangled local the raw
/// pseudo-C renders as an uninitialized read; DatReaderWriter's own
/// typed <c>SkillBase.MinLevel</c> 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 <c>MinLevel</c> in <c>{1, 2}</c> — 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 <c>SkillBase.cs</c> annotates the
/// identical field <c>// 1-2?</c>, a hedge this port never checked.
/// MEASURED against the installed EoR dat's global SkillTable
/// (<see cref="AcDream.App.Tests.UI.Layout.CharacterCreationLiveDatTests.SkillTable_MinLevelDistribution_NeverExceedsTrained"/>):
/// of the 38 priced skills, 23 carry <c>MinLevel == 1</c> and 15 carry
/// <c>MinLevel == 2</c> — 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
/// <see cref="ChargenSkillAdvancementClass.Untrained"/> or
/// <see cref="ChargenSkillAdvancementClass.Inactive"/> would need that
/// gate ported for real regardless of MinLevel's observed range.
/// </summary>
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,
};
}
}
/// <summary>
@ -70,3 +123,54 @@ internal sealed class LiveSkillCreditResolver(SkillTable? skillTable)
: 0u;
}
}
/// <summary>
/// Campaign CC CC5 review fix round, F3 (2026-08-16). Chargen-side sibling
/// of <see cref="LiveSkillCreditResolver"/>: resolves
/// <see cref="RetailSkillFormula.CalculateChargenScore"/> against the SAME
/// global <c>SkillTable</c> (portal.dat <c>0x0E000004</c>), fed by a
/// candidate character's CHARGEN attribute spread (<see cref="ChargenAttributeValues"/>,
/// keyed the same way <c>AcDream.Runtime.Session.ChargenAttributeId</c>
/// already does — verified against DatReaderWriter's own
/// <c>DatReaderWriter.Enums.AttributeId</c> 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
/// (<c>InteractionRetainedUiComposition.cs</c>) so
/// <c>CharacterCreationSummaryPage</c> never needs a DAT/Chorizite
/// dependency of its own — same shape as that composition's existing
/// <c>ResolveText</c> binding.
/// </summary>
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,
};
}

View file

@ -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,
}
/// <summary>
/// Campaign LA slice LA1: a <c>[SupportedOSPlatformGuard]</c>-annotated
/// runtime-OS check, for code OUTSIDE <c>Platform/</c> that needs a
/// CA1416-recognized guard around a Linux-only API (e.g.
/// <c>AppCredentialResolver</c>'s <c>File.GetUnixFileMode</c> call) without
/// re-detecting the OS itself — <c>LinuxPlatformBoundaryTests
/// .OperatingSystemChecksRemainInsidePlatformOwners</c> requires every such
/// check to live under this folder.
/// </summary>
internal static class RuntimePlatformGuard
{
[SupportedOSPlatformGuard("linux")]
internal static bool IsLinuxRuntime => System.OperatingSystem.IsLinux();
}
internal sealed record GraphicalNativeDependency(
string Feature,
string PublishedFileName);

View file

@ -1,4 +1,4 @@
using AcDream.Runtime.Platform;
using AcDream.Platform;
namespace AcDream.App.Platform;

View file

@ -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; }

View file

@ -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.
/// </summary>
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> _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<long, Registration> _registrations = [];
private long _nextRegistrationId;
public void AddMarkupPanel(string markupPath, object binding)
=> _pending.Add(new Pending(markupPath, binding));
=> _ = RegisterMarkupPanel(markupPath, binding);
/// <summary>Return + clear all buffered registrations.</summary>
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);
}
/// <summary>Returns each not-yet-drained active registration once.</summary>
public IReadOnlyList<Pending> Drain()
{
var copy = _pending.ToArray();
_pending.Clear();
return copy;
lock (_gate)
{
var pending = new List<Pending>(_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);
}
}

View file

@ -0,0 +1,99 @@
using AcDream.Core.Plugins;
using AcDream.Platform;
using AcDream.Plugin.Abstractions;
using AcDream.Runtime.Session;
namespace AcDream.App.Plugins;
/// <summary>
/// Graphical-host composition for one plugin set. The shared
/// <see cref="PluginSession"/> owns discovery and collectible lifetimes; this
/// adapter supplies the graphical roots and translates outcomes into the
/// launcher status stream.
/// </summary>
internal sealed class GraphicalPluginSession : IDisposable
{
private readonly PluginSession _plugins;
private readonly string[] _roots;
private readonly IReadOnlyList<string>? _allowList;
private readonly string _sessionId;
private readonly SessionStatusWriter _statusWriter;
private bool _started;
private GraphicalPluginSession(
PluginSession plugins,
string[] roots,
IReadOnlyList<string>? allowList,
string sessionId,
SessionStatusWriter statusWriter)
{
_plugins = plugins;
_roots = roots;
_allowList = allowList;
_sessionId = sessionId;
_statusWriter = statusWriter;
}
internal int LoadedCount => _plugins.LoadedCount;
internal IReadOnlyList<WeakReference> CaptureLoadContextWeakReferences() =>
_plugins.CaptureLoadContextWeakReferences();
internal static GraphicalPluginSession Create(
ApplicationPathSet paths,
IReadOnlyList<string>? 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");
}
}

View file

@ -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");
// Campaign LA slice LA1: --session-config <path> 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(
"--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.
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 <dat-directory> (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 <dat-directory> (or set ACDREAM_DAT_DIR)");
return 2;
}
// 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.FromEnvironment(datDir);
}
if (runtimeOptions.DevTools)
{
@ -67,76 +161,16 @@ var host = new AppPluginHost(
worldEvents,
window.Selection,
uiRegistry);
var loaded = new List<LoadedPlugin>();
var loadedPluginIds = new HashSet<string>(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;

View file

@ -0,0 +1,157 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.World;
namespace AcDream.App.Rendering;
/// <summary>
/// Owns the chargen preview's per-frame idle-loop ↔ rest-pose playback,
/// mirroring <c>gmCG3DView::StartAnimation</c>/<c>StopAnimation</c>'s swap
/// (<c>0x004EE600</c>/<c>0x004EE640</c>) and
/// <c>gmCGAppearancePage::ZoomIn</c>/<c>ZoomOut</c>'s immediate call into it
/// (<c>0x0047D024</c>/<c>0x0047D160</c> — the swap happens the instant the
/// button is pressed, NOT once the camera's own 0.6s tween finishes).
///
/// <para>
/// <b>Retail default is idle-PLAYING, not frozen</b> — see
/// <see cref="ChargenPreviewEntityBuilder"/>'s class doc for the decomp
/// citations. This class's own default (<see cref="IsZoomedIn"/> starts
/// <c>false</c>) reproduces that: its constructor immediately plays the
/// idle animation's frame 0 when one resolved, matching
/// <c>gmCGAppearancePage::Update</c>'s own trailing
/// <c>if (m_bZoomedIn == 0) StartAnimation()</c> gate
/// (~0x0047EF01-0x0047EF12), which re-fires on every heritage/gender/
/// appearance change too — <see cref="SetZoomedIn"/> restarts the idle loop
/// at frame 0 on every transition INTO the playing state for the same
/// reason: <c>set_sequence_animation</c>'s <c>arg3=1</c> clears the sequence
/// before appending, so every <c>StartAnimation</c> call restarts the clip.
/// The DEFAULT-false claim itself rests on <c>gmCGAppearancePage::InitializePage
/// @ 0x0047FDD0</c>'s explicit <c>this-&gt;m_bZoomedIn = 0;</c> at
/// <c>0x004802C3</c> — written immediately after that same function sets the
/// camera to the zoomed-IN per-heritage eye (<c>0x00480286-0x0048029E</c>),
/// not from the ctor simply never touching the field (heap <c>operator new</c>
/// 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.
/// </para>
///
/// <para>
/// The page-mount half (CC6b, after CC4 merges) wires the Zoom In/Out
/// buttons to <see cref="SetZoomedIn"/> and the render loop to
/// <see cref="Tick"/>; nothing in this repository calls either yet.
/// </para>
/// </summary>
internal sealed class ChargenPreviewAnimator
{
/// <summary>
/// <c>gmCG3DView::StartAnimation</c>'s literal framerate argument
/// (<c>set_sequence_animation(this->m_pPlayerObject,
/// this->m_didAnimation.id, 1, 0, 30f)</c>, pseudo-C ~0x004ee61b).
/// </summary>
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<MeshRef>
// 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<MeshRef> _meshRefsBufferA = [];
private readonly List<MeshRef> _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.
}
/// <summary>The live preview entity — mutated in place by <see cref="Tick"/>
/// and <see cref="SetZoomedIn"/>; the renderer never needs to re-call
/// <c>SetPreview</c> after the first assignment (<c>WorldEntity.MeshRefs</c>
/// is read fresh every draw — see its own doc comment).</summary>
public WorldEntity Entity => _build.Entity;
public bool IsZoomedIn => _zoomedIn;
/// <summary>
/// <c>gmCGAppearancePage::ZoomIn</c>/<c>ZoomOut</c>'s
/// <c>StopAnimation</c>/<c>StartAnimation</c> 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 (<c>ZoomIn</c>'s <c>if (m_bZoomedIn != 0) return</c>,
/// <c>ZoomOut</c>'s mirror).
/// </summary>
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();
}
}
/// <summary>
/// Advances the idle loop by <paramref name="elapsedSeconds"/>. No-op
/// while zoomed in (the rest pose is frozen — retail's framerate-0
/// <c>set_sequence_animation</c> call never advances) or when no idle
/// Animation resolved (heritage/DID gap; the entity keeps whatever pose
/// the constructor seeded).
/// </summary>
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<ChargenPreviewDrawablePart> parts = _build.DrawableParts;
List<MeshRef> 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;
}
}

View file

@ -0,0 +1,199 @@
using System;
using System.Numerics;
using AcDream.Core.CharGen;
namespace AcDream.App.Rendering;
/// <summary>
/// Heritage-parameterized camera for the chargen 3D preview
/// (<c>gmCG3DView</c>, Appearance page viewport <c>0x100003bb</c> / Summary
/// <c>0x10000406</c>). Retail-exact eye positions, ported from
/// <c>gmCGAppearancePage::Update @ 0x0047E8F0</c> (pseudo-C ~139037-139114,
/// which sets <c>m_vectTargPosition</c>/<c>m_vectCurPosition</c> 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 <c>gmCGAppearancePage::ZoomIn @
/// 0x0047CF00</c> (pseudo-C ~137618-137638). Direction is always
/// <c>(0,0,0)</c> ⇒ <c>CreatureMode::SetCameraDirection</c> resets the view
/// frame to IDENTITY — the SAME zero-yaw/zero-pitch convention
/// <see cref="DollCamera"/> 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.
///
/// <para>
/// <b>Rotation is NOT a camera property.</b> Retail's continuous-rotation
/// button (<c>gmCGAppearancePage::DoRotation @ 0x0047CA80</c>) advances a
/// HEADING applied to the preview CHARACTER (<c>CPhysicsObj::set_heading</c>
/// inside <c>gmCG3DView::Update</c>, pseudo-C ~242088) — the camera's own
/// position/direction never change during a rotation. The heading itself
/// lives on <see cref="ChargenPreviewRotationController"/> (CC6b: the
/// <c>DoRotation</c>/<c>Rotate</c> port) and is applied to the entity via
/// <c>ChargenPreviewEntityBuilder.TryBuild</c>/<c>TryBuildAnimated</c>'s
/// <c>heading</c> parameter, not here; this class stays a fixed-per-heritage
/// eye, exactly like retail's own camera. <see cref="ChargenPreviewZoomController"/>
/// (CC6b: the <c>ZoomIn</c>/<c>ZoomOut</c>/<c>DoZoomAnimation</c> port) DOES
/// mutate this class's <see cref="Eye"/> — zoom is a camera concern, unlike
/// rotation.
/// </para>
/// </summary>
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);
}
/// <summary>
/// The camera's current world-space eye. Settable so CC6b can react to a
/// heritage change without reconstructing the camera.
/// </summary>
public Vector3 Eye
{
get => _eye;
set => _eye = value;
}
/// <summary>Re-derives <see cref="Eye"/> for the given heritage id (retail's <c>mHeritageGroup</c>).</summary>
public void SetHeritage(uint heritageId) => _eye = ResolveDefaultEye(heritageId);
/// <summary>
/// Retail default (zoomed-in) camera eye per heritage. All four profiles
/// share <c>X=0</c>; only <c>(Y, Z)</c> — 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.
/// </summary>
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),
};
/// <summary>
/// Retail zoomed-OUT camera eye per heritage
/// (<c>gmCGAppearancePage::ZoomOut @ 0x0047D050</c>, 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.
/// </summary>
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),
};
/// <summary>
/// Seconds per 360° revolution for the continuous-rotation button
/// (<c>gmCGAppearancePage::m_dRotationPerSec</c>, 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
/// (<c>gmCGAppearancePage::DoRotation @ 0x0047CA80</c>, pseudo-C
/// ~0x0047CAC7): <c>deltaDegrees = ((now - lastRotateTime) /
/// RotationSecondsPerRevolution) * 360</c> — 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.
/// </summary>
public const float RotationSecondsPerRevolution = 3.0f;
/// <summary>
/// Zoom tween duration in seconds
/// (<c>gmCGAppearancePage::DoZoomAnimation @ 0x0047C960</c>'s
/// reset-if-invalid default, cross-confirmed by <c>ZoomIn</c>/<c>ZoomOut</c>'s
/// own <c>-0.1</c> sentinel write, which deliberately invalidates
/// <c>m_dAnimDuration</c> so the very next <c>DoZoomAnimation</c> 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.
/// <c>DoZoomAnimation</c>'s own reset path: low32 from
/// <c>4.17232506e-08f</c> reinterpreted = <c>0x33333333</c>, high32 =
/// <c>0x3fe33333</c> (clean) → exactly <b>0.6</b>. Cross-check via
/// <c>ZoomIn</c>/<c>ZoomOut</c>'s sentinel: low32 from
/// <c>-1.58818684e-23f</c> reinterpreted = <c>0x9999999A</c>, high32 =
/// <c>0xbfb99999</c> (clean) → exactly <b>-0.1</b>, the well-known
/// IEEE-754 bit pattern for -0.1 (<c>0xBFB999999999999A</c>) — confirming
/// the reconstruction technique itself, not just this one value.
/// </summary>
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);
}
/// <summary>
/// Internal private-viewport adapter, mirroring <c>DollViewportCamera</c>'s
/// role for <see cref="ChargenPreviewCamera"/>.
/// </summary>
internal sealed class ChargenPreviewViewportCamera : IPrivateEntityViewportCamera
{
private readonly ChargenPreviewCamera _camera;
public ChargenPreviewViewportCamera(uint heritageId = 0u)
{
_camera = new ChargenPreviewCamera(heritageId);
}
/// <summary>
/// CC6b-MOUNT seam: wraps an EXTERNALLY-owned <see cref="ChargenPreviewCamera"/>
/// instead of constructing a private one. <see cref="ChargenPreviewZoomController"/>
/// needs a settable <see cref="ChargenPreviewCamera.Eye"/> to tween — the
/// other constructor's private <c>_camera</c> 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.
/// </summary>
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;
}

View file

@ -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;
/// <summary>
/// Campaign CC slice CC6b-MOUNT: the page-mount half's control surface over
/// the CC6a/CC6b-PRE preview foundation. <see cref="CharacterCreationAppearancePage"/>
/// is constructed BEFORE the graphical presentation pipeline exists (early
/// retained-UI composition — see <see cref="AcDream.App.UI.Layout.CharacterCreationRuntimeBindings"/>'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. <see cref="AcDream.App.Composition.LivePresentationComposition"/>
/// constructs the real <see cref="ChargenPreviewController"/> once the
/// graphics backend exists and assigns it onto the page — mirroring exactly
/// how the paperdoll's <c>viewport.Renderer = paperdollLease.Resource</c>
/// late-assignment already works for a DIFFERENT screen's viewport.
/// </summary>
internal interface IChargenPreviewControl
{
/// <summary>
/// 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 <c>PaperdollFramePresenter</c>'s own
/// "keep the successful doll, retry next visible frame" precedent.
/// </summary>
bool Rebuild(
ChargenOptions options,
uint heritageId,
int genderKey,
ChargenAppearanceSelection selection);
void ZoomIn();
void ZoomOut();
void RotateClockwise();
void RotateCounterClockwise();
}
/// <summary>Gates the preview's per-frame work on whether the Appearance
/// PAGE (not just the leaf viewport widget) is the currently visible page —
/// mirrors <c>IPaperdollInventoryVisibility</c>'s outer-frame gate.</summary>
internal interface IChargenPreviewPageVisibility
{
bool IsVisible { get; }
}
/// <summary>CC6b-MOUNT: narrow seam mirroring <c>IPaperdollFrameView</c> so
/// <see cref="ChargenPreviewController"/> can be exercised with a fake view
/// in tests.</summary>
internal interface IChargenPreviewFrameView
{
bool TryGetVisibleSize(out int width, out int height);
void SetTextureHandle(uint textureHandle);
}
/// <summary>Thin adapter over <c>RetailUiRuntime.IsChargenPreviewPageVisible</c>
/// — narrowed to <see cref="IChargenPreviewPageVisibility"/> so this
/// Rendering-namespace class doesn't need a direct dependency on the
/// UI/Layout-namespace <c>RetailUiRuntime</c> type beyond the one property
/// read.</summary>
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;
}
/// <summary>Campaign CC slice CC5: the Summary page's own visibility gate —
/// same shape as <see cref="RetailChargenPreviewPageVisibility"/>, reading
/// <c>RetailUiRuntime.IsSummaryPreviewPageVisible</c> instead.</summary>
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;
}
/// <summary>Retained-UI visibility + texture publication, mirroring
/// <c>RetailPaperdollFrameView</c>.</summary>
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);
}
/// <summary>
/// The real, dat-touching implementation of <see cref="IChargenPreviewControl"/>
/// plus the per-frame <see cref="IPrivateEntityViewportFrame"/> owner —
/// constructed once in <see cref="AcDream.App.Composition.LivePresentationComposition"/>
/// (same composition scope <c>RetailPaperdollPoseApplicator</c> is built in,
/// which has the real <c>content.Dats</c>/<c>content.AnimationLoader</c>/
/// <c>d.DatLock</c>) and assigned onto the already-mounted Appearance page.
///
/// <para>
/// <b>Camera/zoom/rotation ownership (CC6b-MOUNT bridges a CC6a/CC6b-PRE gap):</b>
/// <see cref="ChargenPreviewRenderer"/> only ever built its OWN private
/// <see cref="ChargenPreviewCamera"/> with no injection seam, but
/// <see cref="ChargenPreviewZoomController"/> needs a SETTABLE camera to
/// tween. This class owns the ONE <see cref="ChargenPreviewCamera"/>
/// instance and hands it to the renderer via the new
/// <see cref="ChargenPreviewViewportCamera(ChargenPreviewCamera)"/> overload,
/// so both the renderer's draw and the zoom controller's tween read/write
/// the exact same eye position.
/// </para>
///
/// <para>
/// <b>Rebuild vs per-frame ownership split, decomp-cited (retail
/// <c>gmCGAppearancePage::Update @ 0x0047E8F0</c>):</b> 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 <c>Update</c> —
/// <c>InitializePage</c> and the two gender-button handlers,
/// <c>ListenToElementMessage</c> cases <c>0x9d</c>/<c>0x9e</c>) — spin/color/
/// shade changes call the narrower <c>SetSelection</c>/<c>SetColor</c>/
/// <c>SetShade</c> instead, none of which touch <c>m_vectCurPosition</c>.
/// <see cref="Rebuild"/> reproduces that split: it always recomposes the
/// ObjDesc/mesh (every appearance field feeds <c>gmCG3DView::Update</c>'s
/// rebuild eventually), but only resets the camera when heritage or gender
/// actually changed. <c>m_fCurHeading</c> (this class's
/// <see cref="ChargenPreviewRotationController"/>) and <c>m_bZoomedIn</c>
/// (read through <see cref="ChargenPreviewAnimator.IsZoomedIn"/>) both live
/// on the PAGE in retail and are NEVER reset by <c>Update</c> — so a fresh
/// <see cref="ChargenPreviewAnimator"/> (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.
/// </para>
/// </summary>
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;
/// <param name="camera">The SAME instance passed to the
/// <see cref="ChargenPreviewRenderer"/>'s own <c>camera</c> constructor
/// parameter — see this class's own doc comment on why the renderer and
/// the zoom controller must share one mutable camera.</param>
/// <param name="useZoomedOutEye">Review fix round F5 (2026-08-16):
/// <see langword="false"/> (the default) reproduces the Appearance
/// page's own zoomed-IN default eye
/// (<c>gmCGAppearancePage::InitializePage @ 0x0047FDD0</c>,
/// <see cref="ChargenPreviewCamera.ResolveDefaultEye"/>).
/// <see langword="true"/> reproduces the Summary page's own eye
/// (<c>gmCGSummaryPage::InitializePage @ 0x0047bbf0</c>, byte-decoded
/// eye literal <c>(0, -2.5, 0.95)</c> at <c>~0x0047bd14-0x0047bd44</c> —
/// exactly <see cref="ChargenPreviewCamera.ResolveZoomedOutEye"/>'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):
/// <c>InitializePage</c> alone only justifies the ONE-TIME seed below —
/// the STRONGER citation for why <see cref="Rebuild"/> re-derives this
/// same eye PER HERITAGE on every heritage/gender change (not just
/// once) is <c>gmCGSummaryPage::Update @ 0x0047baa0</c>, which re-sets
/// the camera on every update using the identical per-heritage mapping
/// <see cref="ChargenPreviewCamera.ResolveZoomedOutEye"/> already
/// implements (<c>0xc</c> Olthoi → <c>(0, -3.8, 1.15)</c>, <c>0xd</c>
/// OlthoiAcid → <c>(0, -5.7, 1.65)</c>, else → <c>(0, -2.5, 0.95)</c>) —
/// 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.</param>
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);
}
/// <summary>Test-observability seam only — production callers use
/// <see cref="ZoomIn"/>/<see cref="ZoomOut"/>.</summary>
internal bool IsZoomedIn => _zoom?.IsZoomedIn ?? false;
/// <summary>Test-observability seam only.</summary>
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<T>() 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).
}
}

View file

@ -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;
/// <summary>
/// One resolved drawable part of the chargen preview body — a Setup part
/// index (needed to sample <c>Animation.PartFrames[frame].Frames[index]</c>
/// and <c>Setup.DefaultScale[index]</c>) paired with its resolved GfxObj id,
/// default scale (captured once at build time — scale never changes across
/// an idle cycle), and surface overrides. <see cref="ChargenPreviewAnimator"/>
/// walks this list every tick without touching the dat source again.
/// </summary>
internal readonly record struct ChargenPreviewDrawablePart(
int SetupPartIndex,
uint GfxObjId,
Vector3 DefaultScale,
IReadOnlyDictionary<uint, uint>? SurfaceOverrides);
/// <summary>
/// The richer sibling of <see cref="ChargenPreviewEntityBuilder.TryBuild"/>'s
/// result: the built <see cref="WorldEntity"/> (seeded with retail's true
/// default pose — see <see cref="ChargenPreviewAnimator"/>) 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.
/// </summary>
internal sealed class ChargenPreviewAnimatedBuild
{
public required WorldEntity Entity { get; init; }
public required IReadOnlyList<ChargenPreviewDrawablePart> DrawableParts { get; init; }
/// <summary>
/// The held final-frame rest pose, precomputed once (retail:
/// <c>gmCG3DView::StopAnimation</c>'s framerate-0
/// <c>set_sequence_animation</c> 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 <c>ApplyHeldPose</c> no-op behavior.
/// </summary>
public required IReadOnlyList<MeshRef> RestMeshRefs { get; init; }
/// <summary>Retail's live idle DID (<c>m_didAnimation</c>), or null if unresolved.</summary>
public Animation? IdleAnimation { get; init; }
public int IdleLowFrame { get; init; }
public int IdleHighFrame { get; init; }
}
/// <summary>
/// Builds the chargen preview <see cref="WorldEntity"/> from a
/// <see cref="ChargenAppearanceResult"/> — the App-layer counterpart to
/// <see cref="DollEntityBuilder"/>, 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
/// <see cref="DollEntityBuilder"/>'s pure index-agnostic builder — the
/// closest existing precedent for the actual mesh-flatten/apply-changes/
/// resolve-surface-overrides steps is
/// <c>DatLiveEntityProjectionMaterializer.TryMaterialize</c>, trimmed to
/// what a private, non-collision preview scene needs.
///
/// <para>
/// <b>CC6b:</b> retail's chargen preview does NOT default to a frozen pose —
/// <c>gmCGAppearancePage::Update</c>'s own trailing gate
/// (~0x0047EF01-0x0047EF12) calls <c>gmCG3DView::StartAnimation</c> (idle
/// loop playing) whenever <c>m_bZoomedIn == 0</c>, and that default is
/// DIRECTLY ASSIGNED, not inherited:
/// <c>gmCGAppearancePage::InitializePage @0x0047FDD0</c> writes an
/// explicit <c>m_bZoomedIn = 0</c> at <c>0x004802C3</c> (right after
/// setting the camera to the zoomed-IN per-heritage eye at
/// <c>0x00480286-0x0048029E</c> — 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 (<c>m_didAnimation</c>, 30fps) from
/// the very first frame; the REST pose (<c>m_didAnimationRest</c>, held
/// final frame, this class's pre-CC6b-only behavior) only appears once the
/// user presses Zoom In (<c>gmCGAppearancePage::ZoomIn</c> calls
/// <c>gmCG3DView::StopAnimation</c> immediately, before its camera tween
/// even starts). <see cref="TryBuild"/> keeps its ORIGINAL (rest-only)
/// behavior unchanged for its existing callers; <see cref="TryBuildAnimated"/>
/// plus <see cref="ChargenPreviewAnimator"/> are the new, retail-accurate
/// entry point a live preview (idle-playing by default, freezing on zoom-in)
/// should use.
/// </para>
/// </summary>
internal static class ChargenPreviewEntityBuilder
{
/// <summary>Reserved synthetic guid for the chargen preview clone —
/// same reserved family as <see cref="DollEntityBuilder.DollServerGuid"/>
/// (0xDA11D0xx) and <c>CreatureAppraisalEntityBuilder</c> (0xDA11D02x).</summary>
public const uint PreviewServerGuid = 0xDA11_D031u;
/// <summary>Reserved render-local entity id — passed in
/// <c>animatedEntityIds</c> by the renderer so a re-dress (a new
/// selection) bypasses <c>WbDrawDispatcher</c>'s Tier-1 classification
/// cache, mirroring <see cref="DollEntityBuilder.DollRenderId"/>'s own
/// doc comment.</summary>
public const uint PreviewRenderId = 0xDA11_D032u;
/// <summary>Reserved synthetic guid for the chargen preview's ENVIRONMENT
/// backdrop (GF-7/GF-14 fix) — next slot in the same 0xDA11D03x chargen
/// family as <see cref="PreviewServerGuid"/>.</summary>
public const uint PreviewBackdropServerGuid = 0xDA11_D033u;
/// <summary>Reserved render-local entity id for the backdrop object,
/// passed in <c>animatedEntityIds</c> alongside <see cref="PreviewRenderId"/>
/// so a heritage switch's new environment Setup also bypasses the
/// classification cache — same reasoning as <see cref="PreviewRenderId"/>'s
/// own doc comment, applied to retail's SECOND <c>creature_mode_objects</c>
/// member (<c>gmCG3DView::m_pbgObject</c>).</summary>
public const uint PreviewBackdropRenderId = 0xDA11_D034u;
/// <summary>
/// F16 (Campaign CC gate round 1 closeout, 2026-08-16): the Summary
/// page's OWN preview render-local id — DISTINCT from
/// <see cref="PreviewRenderId"/>. Both the Appearance and Summary pages
/// construct their own <c>ChargenPreviewRenderer</c>, but they share
/// ONE process-wide <c>TextureCache</c> (<c>Wb.IEntityTextureLifetime</c>)
/// via <c>LivePresentationComposition</c>'s <c>foundation.TextureCache</c>
/// — confirmed by tracing <c>FixedEntityTextureOwnerLease.Replace</c> →
/// <c>TextureCache.ReleaseOwner</c> → <c>CompositeTextureArrayCache.ReleaseOwner</c>
/// → its own <c>_owners</c> tracker, keyed ONLY by the raw
/// <c>ownerLocalId</c> uint with no per-renderer namespace. Both pages
/// are mounted as PERMANENT siblings (register AP-229) and can be
/// simultaneously live, so two <c>PrivateEntityViewportRenderer</c>
/// instances sharing <see cref="PreviewRenderId"/> would share this
/// SAME owner bucket: either page re-dressing its own entity (a
/// <c>FixedEntityTextureOwnerLease.Replace</c> call) or being disposed
/// would call <c>ReleaseOwner(PreviewRenderId)</c> 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.
/// </summary>
public const uint SummaryPreviewRenderId = 0xDA11_D035u;
/// <summary>F16: the Summary page's own backdrop render-local id,
/// paired with <see cref="SummaryPreviewRenderId"/> exactly as
/// <see cref="PreviewBackdropRenderId"/> pairs with
/// <see cref="PreviewRenderId"/> — see that constant's own doc for why a
/// distinct id is required, not merely tidy.</summary>
public const uint SummaryPreviewBackdropRenderId = 0xDA11_D036u;
/// <summary>
/// Retail's held-pose (REST) animation DID enum key, resolved through
/// master map slot 7 exactly like <c>RetailPaperdollPoseApplicator.ResolvePoseDid</c>
/// — 0x10000005 for every standard heritage (the SAME enum id the
/// paperdoll's own held pose reads), matching
/// <c>gmCG3DView</c>'s ctor / <c>::Update</c> per-heritage
/// <c>m_didAnimationRest</c> 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.
/// </summary>
private static uint ResolveRestPoseEnum(uint heritageId) => heritageId switch
{
(uint)ChargenHeritageGroup.Olthoi => 0x10000011u,
(uint)ChargenHeritageGroup.OlthoiAcid => 0x10000013u,
_ => 0x10000005u,
};
/// <summary>
/// Retail's LIVE idle-loop animation DID enum key (<c>m_didAnimation</c>,
/// the one <c>gmCG3DView::StartAnimation</c> plays at 30fps) — 0x10000006
/// for every standard heritage, matching <c>gmCG3DView</c>'s ctor /
/// <c>::Update</c> per-heritage assignment (pseudo-C ~0x004ee6cc,
/// ~0x004eec2d). <b>Olthoi and OlthoiAcid use the SAME did for BOTH idle
/// and rest</b> (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.
/// </summary>
private static uint ResolveIdleAnimEnum(uint heritageId) => heritageId switch
{
(uint)ChargenHeritageGroup.Olthoi => 0x10000011u,
(uint)ChargenHeritageGroup.OlthoiAcid => 0x10000013u,
_ => 0x10000006u,
};
/// <summary>
/// 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
/// <see cref="DatLiveEntityProjectionMaterializer"/> treats as "drop this
/// spawn"). Unchanged since CC6a for its RESULT — a thin wrapper over
/// <see cref="TryBuildAnimated"/> that returns exactly the same
/// <c>WorldEntity</c> (rest-posed) this method's existing callers already
/// expect; ALL 3 of those callers' tests still pass unmodified. Not
/// byte-identical internally any more — <see cref="TryBuildAnimated"/>
/// 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
/// <see cref="TryBuildAnimated"/> and wrap the result in a
/// <see cref="ChargenPreviewAnimator"/> instead.
/// </summary>
/// <param name="datLock">
/// Shared exclusion object for every dat read this method performs.
/// <c>DatCollection</c> is NOT thread-safe (see
/// <c>claude-memory/feedback_phase_a1_hotfix_saga.md</c>) — every other
/// dat-touching renderer/resolver in this layer
/// (<c>RetailPaperdollPoseApplicator</c>, <c>PlayerModeController</c>,
/// <c>DatProjectileSetupResolver</c>, <c>EquippedChildRenderController</c>)
/// takes the SAME <c>object datLock</c> the composition root threads
/// through as <c>RuntimeOptions</c>/<c>d.DatLock</c>; callers MUST pass
/// that same shared instance, not a private lock, or this method's reads
/// race every other consumer's.
/// </param>
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;
}
/// <summary>
/// Builds the preview entity PLUS everything a <see cref="ChargenPreviewAnimator"/>
/// needs to drive retail's idle-loop ↔ rest-pose swap without re-touching
/// the dat source. The returned <see cref="ChargenPreviewAnimatedBuild.Entity"/>
/// is initially posed with <see cref="ChargenPreviewAnimatedBuild.RestMeshRefs"/>
/// (cheap, always available) — <see cref="ChargenPreviewAnimator"/>'s
/// constructor immediately reposes it to the true retail default (idle
/// frame 0) when an idle Animation resolved.
/// </summary>
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<ChargenPreviewDrawablePart> drawableParts;
List<MeshRef> 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<Setup>(setupId);
if (setup is null)
return null;
var flattened = new List<MeshRef>(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<int, Dictionary<uint, uint>>? surfaceOverrides =
ResolveSurfaceOverrides(dats, flattened, appearance.ObjDesc.TextureChanges);
drawableParts = new List<ChargenPreviewDrawablePart>(flattened.Count);
restMeshRefs = new List<MeshRef>(flattened.Count);
for (int partIndex = 0; partIndex < flattened.Count; partIndex++)
{
MeshRef part = flattened[partIndex];
if (dats.Get<GfxObj>(part.GfxObjId) is null)
continue; // matches DatLiveEntityProjectionMaterializer's drawable filter.
IReadOnlyDictionary<uint, uint>? 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,
};
}
/// <summary>
/// Builds the chargen preview's ENVIRONMENT BACKDROP entity — the fix for
/// GF-7/GF-14 (preview backdrop black on Appearance and Summary).
///
/// <para>
/// Decomp-cited: <c>gmCG3DView::Update @0x004EE9D0</c>
/// (~0x004eecd3-0x004eed44) constructs a SECOND <c>CPhysicsObj</c> from
/// <c>m_bgSetupID</c> and adds it to the SAME viewport's
/// <c>creature_mode_objects</c> the player object lives in — <b>BEFORE</b>
/// the player is re-added (the player's own re-<c>AddObject</c> happens
/// much later, at ~0x004ef199, after the full clothing ObjDesc is
/// composed), so retail's own draw-list order is backdrop first, player
/// second. <c>m_bgSetupID</c> is compared against a freshly-read value the
/// decompiler elides (<c>var_b8</c>/<c>eax_32</c>, an unresolved-call
/// artifact — see <c>claude-memory/feedback_bn_decomp_field_names.md</c>)
/// immediately after <c>ACCharGenData::GetHG(charGenData, mHeritageGroup)</c>
/// (0x004eea1a) resolves the current heritage's <c>HeritageGroup_CG</c>;
/// <c>acclient.h</c>'s verbatim struct layout
/// (<c>HeritageGroup_CG.environmentSetupID</c>, right after
/// <c>setupID</c>) confirms the elided value IS that field — i.e. THE
/// SAME id this codebase already parses as
/// <see cref="AcDream.Core.CharGen.ChargenHeritageOptions.EnvironmentSetupId"/>
/// (<c>ChargenTableReader.cs</c>) but never consumed. The backdrop object
/// gets NO explicit position/orientation/scale anywhere in the function —
/// <c>CPhysicsObj::makeObject(eax_32, 0, 1)</c> (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.
/// </para>
///
/// <para>
/// Both the Appearance page (<c>gmCGAppearancePage</c>) and the Summary
/// page (<c>gmCGSummaryPage</c>) call this SAME <c>gmCG3DView::Update</c>
/// function on their own <c>gmCG3DView</c> 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.
/// </para>
/// </summary>
/// <param name="environmentSetupId">
/// <see cref="AcDream.Core.CharGen.ChargenHeritageOptions.EnvironmentSetupId"/>.
/// Zero (unset/no environment authored for this heritage) returns null —
/// matches retail's own <c>if (eax_32 != INVALID_DID.id)</c> gate at
/// 0x004eed29, which skips <c>makeObject</c>/<c>AddObject</c> entirely
/// when the heritage has no environment Setup.
/// </param>
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<Setup>(environmentSetupId);
if (setup is null)
return null;
var flattened = SetupMesh.Flatten(setup);
var drawable = new List<MeshRef>(flattened.Count);
foreach (MeshRef part in flattened)
{
if (dats.Get<GfxObj>(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,
};
}
}
/// <summary>No dat access — pure projection of the already-composed
/// ObjDesc's subpalettes, safe to call outside <c>datLock</c>.</summary>
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);
}
/// <summary>No dat access — pure projection, safe to call outside
/// <c>datLock</c>.</summary>
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;
}
/// <summary>
/// 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 <c>RetailPaperdollPoseApplicator.Apply</c>
/// (<c>RedressCreature @ 0x004A3C22</c>), 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.
/// </summary>
private static void ApplyHeldPoseTransforms(
IDatReaderWriter dats,
IAnimationLoader animations,
Setup setup,
uint poseEnum,
List<MeshRef> 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));
}
}
/// <summary>
/// Part-index → (old texture id → new texture id) resolution, verbatim
/// port of <c>DatLiveEntityProjectionMaterializer.ResolveSurfaceOverrides</c>'s
/// algorithm against <see cref="ChargenTextureChange"/> instead of the
/// wire's <c>CreateObject.TextureChange</c>.
/// </summary>
private static Dictionary<int, Dictionary<uint, uint>>? ResolveSurfaceOverrides(
IDatReaderWriter dats,
IReadOnlyList<MeshRef> parts,
IReadOnlyList<ChargenTextureChange> textureChanges)
{
if (textureChanges.Count == 0)
return null;
var oldToNewByPart = new Dictionary<int, Dictionary<uint, uint>>();
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<int, Dictionary<uint, uint>>();
for (int partIndex = 0; partIndex < parts.Count; partIndex++)
{
if (!oldToNewByPart.TryGetValue(partIndex, out var oldToNew))
continue;
GfxObj? gfx = dats.Get<GfxObj>(parts[partIndex].GfxObjId);
if (gfx is null)
continue;
Dictionary<uint, uint>? resolved = null;
foreach (var surfaceQid in gfx.Surfaces)
{
uint surfaceId = (uint)surfaceQid;
Surface? surface = dats.Get<Surface>(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;
}
}

View file

@ -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;
/// <summary>
/// CC6b-MOUNT: narrow seam mirroring <c>IPaperdollDollRenderer</c> so
/// <see cref="ChargenPreviewController"/>'s rebuild/render logic can be
/// exercised with a fake in tests without a live GPU device.
/// </summary>
internal interface IChargenPreviewRenderer
{
void SetPreview(WorldEntity? entity);
/// <summary>
/// Sets or clears the environment backdrop entity drawn BEHIND the
/// preview (Campaign CC gate round 1 Batch D, GF-7/GF-14) — retail's
/// <c>gmCG3DView::m_pbgObject</c>. See
/// <see cref="ChargenPreviewEntityBuilder.TryBuildBackdrop"/> for the
/// decomp-cited placement.
/// </summary>
void SetBackdrop(WorldEntity? entity);
uint Render(int width, int height);
}
/// <summary>
/// Chargen-specific facade over the shared private creature viewport
/// (<see cref="PrivateEntityViewportRenderer"/>) — CC6a's foundation half of
/// the campaign plan's "chargen preview renderer" deliverable. Mirrors
/// <see cref="PaperdollViewportRenderer"/>'s shape exactly, with a
/// heading-capable <see cref="ChargenPreviewViewportCamera"/> in place of the
/// paperdoll's fixed one.
///
/// <para>
/// <b>NOT wired here (CC6b page-mount half, after CC4 merges per the
/// campaign's parallelism contract):</b> mounting into the authored
/// Appearance/Summary viewport ids (<c>0x100003bb</c> / <c>0x10000406</c>)
/// and binding the spin/color-wheel/rotate/zoom widgets to
/// <see cref="ChargenPreviewAnimator"/>/<see cref="ChargenPreviewRotationController"/>/
/// <see cref="ChargenPreviewZoomController"/>. This class is a standalone,
/// composition-root-agnostic renderer — nothing in
/// <c>AcDream.App/UI/Layout/</c> or <c>RetailUiRuntime.cs</c> references it
/// yet.
/// </para>
///
/// <para>
/// <b>CC6b (pre-mount half):</b> the preview now HAS a real live idle loop
/// (<see cref="ChargenPreviewAnimator"/>, retail's <c>m_didAnimation</c> DID
/// at 30fps via <c>set_sequence_animation</c>) instead of the CC6a-only held
/// rest pose — TS-83 is retired. <see cref="SetPreview"/> still accepts a
/// static <c>WorldEntity</c> for callers that only want
/// <c>ChargenPreviewEntityBuilder.TryBuild</c>'s unchanged rest-pose
/// snapshot; a caller that wants the animated preview constructs a
/// <see cref="ChargenPreviewAnimator"/> from
/// <c>ChargenPreviewEntityBuilder.TryBuildAnimated</c> and passes its
/// <c>Entity</c> here once — the animator mutates that SAME entity's
/// <c>MeshRefs</c> in place every <c>Tick</c>, and <c>Render</c> reads it
/// fresh (no re-<c>SetPreview</c> needed per frame; see
/// <c>WorldEntity.MeshRefs</c>'s own "mutable so the animation tick can
/// replace it each frame" doc comment).
/// </para>
/// </summary>
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;
/// <summary>
/// Re-derives the fixed per-heritage camera eye
/// (<see cref="ChargenPreviewCamera.ResolveDefaultEye"/>) — call whenever
/// the selected heritage changes, BEFORE the next <see cref="Render"/>.
/// </summary>
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();
}

View file

@ -0,0 +1,176 @@
using System.Numerics;
using AcDream.Core.Physics.Motion;
namespace AcDream.App.Rendering;
/// <summary>
/// Retail's toggle direction enum
/// (<c>gmBarberUI::ERotateDirection</c>/<c>gmCGAppearancePage::ERotateDirection</c>
/// typedef alias, <c>acclient.h:6848-6852,6960</c>): <c>Invalid=0</c>,
/// <c>Clockwise=1</c>, <c>CounterClockwise=2</c>.
/// </summary>
internal enum ChargenRotateDirection
{
Invalid = 0,
Clockwise = 1,
CounterClockwise = 2,
}
/// <summary>
/// Presentation-free port of <c>gmCGAppearancePage::Rotate</c>
/// (<c>0x0047CB50</c>) + <c>DoRotation</c> (<c>0x0047CA80</c>) — the
/// button-toggled continuous rotation retail applies to the preview
/// CHARACTER's heading (<c>CPhysicsObj::set_heading</c> inside
/// <c>gmCG3DView::Update</c>, pseudo-C ~0x0047eecf1), not the camera (see
/// <see cref="ChargenPreviewCamera"/>'s own doc comment on why rotation
/// lives here instead). Retail drives <see cref="Tick"/> once per frame from
/// a global-message-3 tick while <see cref="IsRotating"/> is set
/// (<c>gmCGAppearancePage::ListenToGlobalMessage @ 0x0047CED0</c>); the
/// CC6b page-mount half will bind the Rotate Clockwise/Counter-Clockwise
/// buttons to <see cref="Toggle"/> and the render loop to <see cref="Tick"/>.
/// </summary>
internal sealed class ChargenPreviewRotationController
{
/// <summary>
/// <c>Rotate</c>'s explicit sentinel write
/// (<c>this->m_dLastRotateTime = -1.0</c>, pseudo-C ~0x0047cba7/0x0047cbb1
/// — the high dword <c>0xbff00000</c> paired with a zero low dword is the
/// exact IEEE-754 bit pattern for <c>-1.0</c>) — invalidates the
/// timestamp so the very next <see cref="Tick"/> resets it to "now"
/// (a zero-length first delta) instead of computing a huge jump from a
/// stale or never-set value.
/// </summary>
private const double InvalidTimeSentinel = -1.0;
private double _lastRotateTime = InvalidTimeSentinel;
private ChargenRotateDirection _direction = ChargenRotateDirection.Invalid;
private bool _rotating;
/// <summary>
/// CC6b-MOUNT: retail's true OPERATIVE starting heading — not the ctor's
/// value. <c>gmCGAppearancePage::gmCGAppearancePage @0x0047CCC0</c> sets
/// <c>m_fCurHeading = 0f</c> at <c>0x0047CDAC</c>, but
/// <c>gmCGAppearancePage::InitializePage @0x0047FDD0</c> — which always
/// runs immediately afterward, before the page is ever visible — writes
/// <c>m_fCurHeading = 180f</c> at <c>0x00480235</c> and pushes it into the
/// view via <c>gmCG3DView::SetPlayerHeading(m_p3DView, 180f)</c> at
/// <c>0x0048023F</c>. 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 <c>gmCG3DView</c> owner does for ITS own instance:
/// <c>gmCGSummaryPage::InitializePage @0x0047BD54</c> (a separate
/// viewport/page, CC5's scope, not this one) and <c>gmBarberUI</c>
/// corroborate 180 TWICE, in two separate functions (fix round F4
/// correction — the original citation here wrongly attributed both
/// writes to <c>PostInit</c>): <c>gmBarberUI::PostInit @0x004de2e0</c>
/// has its OWN <c>m_fCurHeading = 180f</c> write at <c>0x004de330</c>
/// (no push there — <c>PostInit</c> ends right after that assignment);
/// separately, <c>gmBarberUI::InitializePage @0x004e0040</c> has its OWN
/// redundant <c>m_fCurHeading = 180f</c> write at <c>0x004e03ab</c>,
/// THEN pushes it via <c>SetPlayerHeading(m_p3DView, 180f)</c> at
/// <c>0x004e03b5</c> — the address the original citation attributed to
/// <c>PostInit</c>. Two functions, both landing on 180, not one
/// function pushing from the other's write. Since
/// this controller — like retail's <c>m_fCurHeading</c> — 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).
/// </summary>
public const float RetailDefaultHeadingDegrees = 180f;
public bool IsRotating => _rotating;
public ChargenRotateDirection Direction => _direction;
/// <param name="initialHeadingDegrees">Defaults to
/// <see cref="RetailDefaultHeadingDegrees"/> (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 <c>0f</c> explicitly for simpler
/// relative-delta assertions; that is a test convenience, not a second
/// retail-cited default.</param>
public ChargenPreviewRotationController(
float initialHeadingDegrees = RetailDefaultHeadingDegrees)
{
HeadingDegrees = initialHeadingDegrees;
}
/// <summary>Retail's <c>m_fCurHeading</c>, degrees — applied to the
/// preview entity via <c>MoveToMath.SetHeading</c>
/// (<c>CPhysicsObj::set_heading</c>'s exact port). See
/// <see cref="RetailDefaultHeadingDegrees"/> for why this controller's
/// parameterless-constructor default is 180, not the ctor's raw 0.
/// </summary>
public float HeadingDegrees { get; private set; }
/// <summary>
/// <c>gmCGAppearancePage::Rotate @ 0x0047CB50</c>: 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 <c>m_dLastRotateTime</c> per this class's own sentinel
/// doc.
/// </summary>
public void Toggle(ChargenRotateDirection direction)
{
if (_rotating && direction == _direction)
{
_rotating = false;
return;
}
_direction = direction;
_lastRotateTime = InvalidTimeSentinel;
_rotating = true;
}
/// <summary>
/// <c>gmCGAppearancePage::DoRotation @ 0x0047CA80</c>: per-tick
/// <c>deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution)
/// * 360</c>, added for <see cref="ChargenRotateDirection.Clockwise"/>
/// and subtracted for every other direction (pseudo-C ~0x0047cacd:
/// <c>if (m_eRotateDir != ECG_ROTATE_CLOCKWISE) heading -= delta; else
/// heading += delta;</c>), then a SINGLE-PASS clamp back into
/// <c>[0, 360)</c> — 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 <c>x87_r7_1 = x87_r6_3</c> at <c>0x0047CAEB</c>
/// inside the counter-clockwise branch — reassigning the local that held
/// the "now" timestamp to the just-computed delta-degrees value — which
/// would make the <c>0x0047CB3D</c> store into <c>m_dLastRotateTime</c>
/// 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
/// <c>claude-memory/feedback_bn_decomp_field_names.md</c> names exactly
/// this x87-stack-register mislabeling as a known decompiler artifact
/// class), so this port stores <c>now</c> into <c>_lastRotateTime</c>
/// unconditionally in BOTH directions.
/// </summary>
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;
}
/// <summary><c>CPhysicsObj::set_heading</c>'s exact quaternion
/// construction — the SAME shared Core primitive retail movement already
/// ports (<see cref="MoveToMath.SetHeading"/>).</summary>
public Quaternion ToOrientation() =>
MoveToMath.SetHeading(Quaternion.Identity, HeadingDegrees);
}

View file

@ -0,0 +1,162 @@
using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>
/// Presentation-free port of <c>gmCGAppearancePage::ZoomIn</c>/<c>ZoomOut</c>
/// (<c>0x0047CF00</c>/<c>0x0047D050</c>) and <c>DoZoomAnimation</c>
/// (<c>0x0047C960</c>): a linear 0.6s tween of the preview camera's eye
/// between <see cref="ChargenPreviewCamera.ResolveDefaultEye"/> (zoomed IN)
/// and <see cref="ChargenPreviewCamera.ResolveZoomedOutEye"/> (zoomed OUT),
/// driving the SAME <see cref="ChargenPreviewAnimator"/> zoom-state swap the
/// button presses trigger in retail — immediately, not once the tween
/// finishes (see <see cref="ChargenPreviewAnimator"/>'s own doc comment).
///
/// <para>
/// <b>One owner of the zoom state (fix round F2):</b> retail's
/// <c>m_bZoomedIn</c> is a SINGLE field on <c>gmCGAppearancePage</c> 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 <see cref="ChargenPreviewAnimator"/>) synced only by
/// <see cref="ZoomIn"/>/<see cref="ZoomOut"/> calling a NULLABLE animator
/// parameter — a null pass, or any direct
/// <see cref="ChargenPreviewAnimator.SetZoomedIn"/> call bypassing this
/// controller, would desync the camera's target from the animation's pose.
/// This class now takes its <see cref="ChargenPreviewAnimator"/> as a
/// REQUIRED constructor dependency and <see cref="IsZoomedIn"/> reads
/// straight through to <see cref="ChargenPreviewAnimator.IsZoomedIn"/> — 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.
/// </para>
///
/// <para>
/// Retail drives <see cref="Tick"/> once per frame from a global-message-3
/// tick while <c>m_bShouldZoomAnimate</c> is set
/// (<c>gmCGAppearancePage::ListenToGlobalMessage @ 0x0047CED0</c>); the
/// CC6b page-mount half will bind the Zoom In/Out buttons to
/// <see cref="ZoomIn"/>/<see cref="ZoomOut"/> and the render loop to
/// <see cref="Tick"/>. Direction is always <c>(0,0,0)</c> for this camera
/// (see <see cref="ChargenPreviewCamera"/>'s own remarks), so only the eye
/// position tweens — retail's own <c>m_vectCurDirection</c> lerp is a no-op
/// here and is not reproduced.
/// </para>
/// </summary>
internal sealed class ChargenPreviewZoomController
{
/// <summary>
/// <c>ZoomIn</c>/<c>ZoomOut</c>'s explicit invalidation write
/// (<c>this->m_dAnimDuration = -0.1</c>, pseudo-C ~0x0047cff1/0x0047cffb
/// and ~0x0047d12c/0x0047d136 — the exact IEEE-754 bit pattern for
/// <c>-0.1</c>) so the very next <see cref="Tick"/> resets the duration
/// to <see cref="ChargenPreviewCamera.ZoomTweenDurationSeconds"/> and the
/// start time to "now", matching <c>DoZoomAnimation</c>'s own
/// reset-if-invalid guard exactly.
/// </summary>
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; }
/// <summary>
/// Mirrors retail's <c>m_bZoomedIn</c> — a straight read-through to
/// <see cref="ChargenPreviewAnimator.IsZoomedIn"/> (see this class's own
/// "one owner" doc above), which itself defaults false per
/// <c>gmCGAppearancePage::InitializePage @ 0x0047FDD0</c>'s explicit
/// <c>this-&gt;m_bZoomedIn = 0;</c> at <c>0x004802C3</c> — written right
/// after that same function points the camera at the zoomed-IN
/// per-heritage eye (<c>0x00480286-0x0048029E</c>). 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.
/// </summary>
public bool IsZoomedIn => _animator.IsZoomedIn;
/// <summary>
/// <c>gmCGAppearancePage::ZoomIn @ 0x0047CF00</c>: 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
/// (<c>gmCG3DView::StopAnimation</c>'s call site, pseudo-C ~0x0047d024,
/// precedes the tween's own completion by definition — it runs once,
/// synchronously, inside <c>ZoomIn</c> itself).
/// </summary>
public void ZoomIn()
{
if (IsZoomedIn)
return;
StartTween(ChargenPreviewCamera.ResolveDefaultEye(_heritageId));
_animator.SetZoomedIn(true);
}
/// <summary>
/// <c>gmCGAppearancePage::ZoomOut @ 0x0047D050</c>: 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 <see cref="ZoomIn"/>.
/// </summary>
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;
}
/// <summary>
/// <c>gmCGAppearancePage::DoZoomAnimation @ 0x0047C960</c>: a LINEAR
/// (not eased) lerp of the eye position from <c>m_vectStartPosition</c>
/// to <c>m_vectTargPosition</c> over
/// <see cref="ChargenPreviewCamera.ZoomTweenDurationSeconds"/>, clamping
/// <c>t</c> to exactly 1.0 (and clearing <c>m_bShouldZoomAnimate</c>) the
/// tick that reaches or passes the duration — the decomp shows a
/// straight <c>(targ - start) * t + start</c> per axis with no easing
/// curve applied anywhere in this function.
/// </summary>
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);
}
}

View file

@ -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<string>? _resolutions;
private static IReadOnlyList<string>? _windowedResolutions;
private static string? _desktopResolution;
/// <summary>The curated list, or null when no catalog was installed
/// (fixture/headless callers — consumers fall back to the static
/// preset ladder).</summary>
/// <summary>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.</summary>
public static IReadOnlyList<string>? Resolutions => _resolutions;
/// <summary>#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 <see cref="Resolutions"/> + 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).</summary>
public static IReadOnlyList<string>? WindowedResolutions => _windowedResolutions;
/// <summary>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;
}
/// <summary>
/// #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
/// <see cref="Curate"/> emits so the dropdown reads identically on
/// physical and remote displays.
/// </summary>
internal static IReadOnlyList<string> BuildWindowedOffering(
IReadOnlyList<string> 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;
}
}
/// <summary>
/// The pure curation rule (#391): keep a mode iff
/// - it is a modern widescreen format (16:9, 16:10, or ultrawide 21:9 /

View file

@ -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);
}
/// <summary>
/// Transfers the graphical plugin lifetime into the window shutdown graph
/// and starts it before retained UI construction drains registrations.
/// </summary>
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);
}
/// <summary>
/// Writes the ONE terminal "exited" status event for this session
/// (fix #406). A resource-shutdown transaction can converge cleanly
/// (<paramref name="report"/>'s own <see cref="GameWindowLifetimeReport.Status"/>
/// says nothing about this) even though this <see cref="Dispose"/> call
/// is running mid-unwind of an exception that escaped
/// <see cref="Run"/>'s frame loop and is about to terminate the process
/// via the CLR's unhandled-exception path — <see cref="_runFailure"/>
/// 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).
/// </summary>
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,

View file

@ -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<PortalTunnelPresentation> 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()),

View file

@ -335,29 +335,11 @@ internal sealed class RetailPaperdollPoseApplicator : IPaperdollPoseApplicator
/// <summary>
/// Retail <c>gmPaperDollUI</c> resolves its held pose with
/// <c>DBCache::GetDIDFromEnumStatic(0x10000005, 7)</c>. The master map
/// therefore resolves key 7 to a sub-map, then key 0x10000005 to the
/// Animation DID.
/// <c>DBCache::GetDIDFromEnumStatic(0x10000005, 7)</c>
/// <see cref="RetailHeldPose.ResolvePoseDid"/> parameterized by the
/// paperdoll's own fixed enum key.
/// </summary>
private uint ResolvePoseDid()
{
uint masterDid = (uint)_dats.Portal.Db.Header.MasterMapId;
if (masterDid == 0
|| !_dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(
masterDid,
out var master)
|| !master.ClientEnumToID.TryGetValue(7u, out uint subDid)
|| !_dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(
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)
{

View file

@ -44,6 +44,20 @@ internal interface IPrivateEntityViewportCamera : ICamera
/// raw-GL <c>WbDrawDispatcher</c> into (through V10, §5.5.6) was deleted at
/// Campaign V slice V11: <c>WbDrawDispatcher</c> now records into the pass
/// this renderer publishes on both call sites the same way.</para>
///
/// <para>
/// <b>Campaign CC gate round 1, Batch D (GF-7/GF-14).</b> Retail's
/// <c>gmCG3DView::Update @0x004EE9D0</c> draws a SECOND private entity — a
/// heritage-authored environment Setup (<c>m_pbgObject</c>) — behind the main
/// one, in the SAME <c>creature_mode_objects</c> list. This renderer now
/// supports that as an OPTIONAL second entity slot, reserved at construction
/// via <paramref name="backdropRenderId"/>-shaped ctor param (see below) —
/// paperdoll and creature-appraisal never pass one, so <see cref="SetBackdrop"/>
/// throws for them rather than silently doing nothing (the slot does not
/// exist). See <see cref="ChargenPreviewEntityBuilder.TryBuildBackdrop"/> for
/// the full decomp citation of the backdrop's placement (unposed, at the
/// scene origin, added to the draw list BEFORE the main entity).
/// </para>
/// </summary>
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<uint> _animatedIds;
private readonly string _diagnosticName;
private readonly List<SyntheticEntityMeshReferenceOwner>
_retiringMeshReferences = [];
private readonly EntitySlot _mainSlot;
/// <summary>Null for every renderer that never reserved a
/// <c>backdropRenderId</c> (paperdoll, creature-appraisal) — the backdrop
/// feature does not exist for them, not just "unused".</summary>
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];
}
/// <summary>
@ -125,65 +167,27 @@ internal sealed class PrivateEntityViewportRenderer :
/// </summary>
public bool TextureIsBottomUp => false;
public void SetEntity(WorldEntity? entity)
{
ReleaseRetiringMeshReferences();
public void SetEntity(WorldEntity? entity) => _mainSlot.Set(entity);
if (ReferenceEquals(_entity, entity))
return;
SyntheticEntityMeshReferenceOwner? replacement = null;
if (entity is not null)
/// <summary>
/// Sets or clears the environment backdrop entity drawn BEHIND the main
/// entity — GF-7/GF-14's fix, retail's <c>gmCG3DView::m_pbgObject</c>. Only
/// valid on a renderer constructed with a <c>backdropRenderId</c>
/// (<see cref="ChargenPreviewRenderer"/>'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.
/// </summary>
public void SetBackdrop(WorldEntity? entity)
{
replacement = new SyntheticEntityMeshReferenceOwner(
_meshAdapter,
CollectMeshIds(entity));
replacement.Acquire();
if (_backdropSlot is null)
{
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);
}
/// <summary>
@ -193,7 +197,7 @@ internal sealed class PrivateEntityViewportRenderer :
/// </summary>
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<WorldEntity> drawEntities = BuildDrawEntities(_backdropSlot?.Entity, entity);
var entries =
new (uint, Vector3, Vector3, IReadOnlyList<WorldEntity>,
IReadOnlyDictionary<uint, WorldEntity>?)[]
@ -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);
}
/// <summary>
/// Pure helper assembling this frame's draw-entity list in retail's own
/// insertion order — <c>gmCG3DView::Update</c> adds the backdrop object to
/// <c>creature_mode_objects</c> BEFORE the main (player) object is
/// re-added (the player's own re-<c>AddObject</c> happens much later, at
/// ~0x004ef199, after the full clothing ObjDesc composes — see
/// <see cref="ChargenPreviewEntityBuilder.TryBuildBackdrop"/>'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
/// <c>PrivateEntityViewportRendererDrawOrderTests</c> without needing a
/// live GPU device or a constructed <see cref="WbDrawDispatcher"/>.
/// </summary>
internal static IReadOnlyList<WorldEntity> BuildDrawEntities(WorldEntity? backdrop, WorldEntity main) =>
backdrop is not null && backdrop.MeshRefs.Count > 0
? [backdrop, main]
: [main];
/// <summary>
/// Both retail paperdoll and creature examination call
/// <c>UIElement_Viewport::SetLight(DISTANT_LIGHT, 2, (0.3,1.9,0.65))</c>.
/// Byte-decoded confirmation (Batch D re-derivation): the SAME three
/// float32 constants (<c>0x3e99999a</c>/<c>0x3ff33333</c>/<c>0x3F266666</c>
/// = 0.3/1.9/0.65) appear verbatim at <c>gmCG3DView::Update</c>'s own
/// <c>SetLight</c> call site (pseudo-C ~0x004eecd3-0x004eece3) — the
/// chargen preview uses the EXACT same light this method already ported,
/// not a different value.
/// </summary>
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<Exception>? 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,6 +413,133 @@ internal sealed class PrivateEntityViewportRenderer :
yield return entity.PartOverrides[i].GfxObjId;
}
/// <summary>
/// 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 <see cref="PrivateEntityViewportRenderer.SetEntity"/>'s
/// pre-Batch-D body.
/// </summary>
private sealed class EntitySlot
{
private readonly IWbMeshAdapter _meshAdapter;
private readonly FixedEntityTextureOwnerLease _textureOwnerLease;
private readonly string _diagnosticName;
private readonly List<SyntheticEntityMeshReferenceOwner> _retiringMeshReferences = [];
private SyntheticEntityMeshReferenceOwner? _meshReferences;
public EntitySlot(
IWbMeshAdapter meshAdapter,
IEntityTextureLifetime textureLifetime,
uint ownerLocalId,
string diagnosticName)
{
_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
{
_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<Exception>? 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);
}
}
private void ReleaseRetiringMeshReferences()
{
List<Exception>? failures = null;
@ -418,3 +567,4 @@ internal sealed class PrivateEntityViewportRenderer :
}
}
}
}

View file

@ -0,0 +1,61 @@
using System.Numerics;
using AcDream.Content;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Rendering;
/// <summary>
/// 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 <see cref="RetailPaperdollPoseApplicator"/> (paperdoll,
/// <c>gmPaperDollUI::RedressCreature @ 0x004A3C22</c>) and
/// <see cref="ChargenPreviewEntityBuilder"/> (chargen preview,
/// <c>gmCG3DView::StopAnimation @ 0x004EE640</c>) both implement. Extracted
/// per the CC6a review's F11/F12 note ("before adding a FOURTH consumer... a
/// shared <c>RetailHeldPose</c> 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 <c>WorldEntity.MeshRefs</c>; chargen
/// walks the pre-filter, Setup-part-indexed scratch list) into one method
/// they don't actually share.
/// </summary>
internal static class RetailHeldPose
{
/// <summary>
/// <c>DBCache::GetDIDFromEnumStatic(poseEnum, 7)</c> equivalent: master
/// map → slot 7's sub-map → <paramref name="poseEnum"/>'s Animation DID.
/// Returns 0 if any link in the chain is missing. MUST be called under
/// the caller's dat lock (see <see cref="ChargenPreviewEntityBuilder.TryBuild"/>'s
/// <c>datLock</c> doc — <c>DatCollection</c> is not thread-safe).
/// </summary>
public static uint ResolvePoseDid(IDatReaderWriter dats, uint poseEnum)
{
uint masterDid = (uint)dats.Portal.Db.Header.MasterMapId;
if (masterDid == 0
|| !dats.Portal.TryGet<EnumIDMap>(masterDid, out var master)
|| !master.ClientEnumToID.TryGetValue(7u, out uint subDid)
|| !dats.Portal.TryGet<EnumIDMap>(subDid, out var sub))
{
return 0u;
}
return sub.ClientEnumToID.TryGetValue(poseEnum, out uint did) ? did : 0u;
}
/// <summary>
/// Retail's per-part pose transform: <c>Scale(defaultScale) *
/// Rotate(orientation) * Translate(origin)</c> — the SAME composition
/// both <c>RetailPaperdollPoseApplicator.Apply</c> and
/// <see cref="ChargenPreviewEntityBuilder"/>'s pose steps use, whether
/// the (origin, orientation) pair comes from a held final frame or an
/// interpolated idle-cycle frame.
/// </summary>
public static Matrix4x4 ComposePartTransform(Vector3 defaultScale, Vector3 origin, Quaternion orientation) =>
Matrix4x4.CreateScale(defaultScale)
* Matrix4x4.CreateFromQuaternion(orientation)
* Matrix4x4.CreateTranslation(origin);
}

View file

@ -201,6 +201,38 @@ public sealed class TextRenderer : IDisposable
});
}
/// <summary>
/// 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 (<see cref="AppendQuad"/>). 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, <see cref="UiRoot"/> 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.
/// </summary>
internal Vector2 CanvasScale = Vector2.One;
/// <summary>
/// Campaign LA gate round 2 (register AD-98 filtering fidelity): resolves a
/// UI texture handle to its linear-sampled twin
/// (<see cref="TextureCache.GetOrCreateLinearUiTwin"/>), consulted by
/// <see cref="DrawSprite"/> only while <see cref="CanvasScale"/> is not One.
/// Wired once by the composition root right after <c>TextureCache</c> 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.
/// </summary>
internal Func<uint, uint>? LinearTwinResolver { get; set; }
/// <summary>Begin a HUD pass. Call once per frame before any Draw* calls.</summary>
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<float> buf,
private void AppendQuad(List<float> 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.

View file

@ -40,12 +40,33 @@ public sealed class TextureCache
// Surface→SurfaceTexture chain that GetOrUpload uses for world materials.
private readonly Dictionary<uint, GpuUiTextureEntry> _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<uint> _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<GpuUiTextureEntry> _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<uint, IGpuTexture> _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<uint, uint> _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
}
}
/// <summary>
/// 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.
///
/// <para>
/// 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
/// <see cref="AcDream.App.UI.UiRoot.FixedCanvasSize"/>'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 <see cref="TextRenderer.CanvasScale"/> 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.
/// </para>
///
/// <para>
/// Returns <paramref name="handle"/> UNCHANGED for anything this cache never
/// registered nearest — chrome/background art already samples
/// <see cref="GpuSamplerDescription.WorldRepeat"/> (linear) and has nothing to
/// swap, and <see cref="UiTextureTableHandle.None"/> (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
/// <see cref="TextRenderer.DrawSprite"/> can call it unconditionally whenever
/// the canvas is scaled.
/// </para>
///
/// <para>
/// The twin reuses the ORIGINAL <see cref="IGpuTexture"/> — 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
/// <see cref="RegisterWorldSurface"/>'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.
/// </para>
/// </summary>
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;
}
/// <summary>
/// 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-

View file

@ -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<AdapterSubscription> _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<IRuntimeCharacterSelectionCommands, RuntimeCommandResult> 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<IRuntimeCharacterCreationCommands, RuntimeCommandResult> 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

View file

@ -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,
/// <summary>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
/// (<c>0x100003A0</c>), which stays ghosted until CC7's closing move.
/// See <c>CharacterCreationRuntimeBindings.OpenOnStart</c>.</summary>
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,
/// <summary>Campaign LA slice LA1: the raw <c>--session-config</c> path,
/// or <see langword="null"/> when the flag was not supplied (the env-var
/// dev flow). Kept for diagnostics/logging only.</summary>
string? SessionConfigPath,
/// <summary>Campaign LA slice LA1: the configured session's id, used as
/// the <c>sessionId</c> field on every status-stream event. Defaults to
/// <c>"app"</c> at every call site when unset (env-var flow).</summary>
string? SessionId,
/// <summary>Campaign LA slice LA1: the session-config character
/// selector, or <see langword="null"/> 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).</summary>
LiveSessionCharacterSelector? LiveCharacterSelector,
/// <summary>Campaign LA slice LA1: absolute path for the status-event
/// JSONL stream. <see langword="null"/> = no writer constructed.</summary>
string? StatusFilePath,
/// <summary>Campaign LA slice LA1: plugin ids to load.
/// <see langword="null"/> = load every discovered plugin (today's
/// behavior). Consumed by the shared graphical plugin session.</summary>
IReadOnlyList<string>? Plugins,
/// <summary>Campaign LA slice LA1: ordered chat-typed strings run once
/// entered-world through the shared Runtime parser/router.</summary>
IReadOnlyList<string> LoginCommands,
/// <summary>Campaign LA slice LA1: inter-command delay for
/// <see cref="LoginCommands"/>, milliseconds.</summary>
int LoginCommandDelayMs)
{
/// <summary>
/// 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);
}
/// <summary>
/// Campaign LA slice LA1: builds options for the <c>--session-config</c>
/// launch path. Starts from the same env-var parse as
/// <see cref="FromEnvironment"/> (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.
/// <paramref name="resolvedPassword"/> is revealed into
/// <see cref="LivePass"/> exactly as wide as the existing env-var flow —
/// see that field's own doc.
/// </summary>
internal static RuntimeOptions FromSessionConfig(
string datDir,
Func<string, string?> 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<string>?)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();
/// <summary>
/// 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
/// <see cref="object.ToString"/> path.
/// </summary>
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
? "<redacted>"
: property.GetValue(this));
}
return PrintableProperties.Length != 0;
}
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary>

View file

@ -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))
{

View file

@ -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;
}

View file

@ -24,6 +24,22 @@ public static class RetailUiStateIds
public const uint LockedUi = 0x10000063u;
public const uint UnlockedUi = 0x10000064u;
/// <summary>
/// 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 (<c>0x100003BF</c>), Profession template (<c>0x100003D9</c>),
/// Appearance Face/Clothes sub-tabs (<c>0x100003A9</c>/<c>0x100003AA</c>),
/// and gender buttons (<c>0x100003A7</c>/<c>0x100003A8</c>). Named
/// <c>UiStateInfo.Name</c> 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 <see cref="AcDream.App.UI.UiButton"/>'s custom-selection-pair
/// bypass in <c>UpdateVisualState</c>.
/// </summary>
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;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,282 @@
using System.Numerics;
using AcDream.Core.CharGen;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.UI.Layout;
/// <summary>
/// The Heritage page (<c>gmCGHeritagePage</c>, root <c>0x100003d1</c>) — 13
/// race buttons and the composed description text. Decomp anchors:
/// <c>gmCGHeritagePage::InitializePage @ 0x00483a10</c> (button ids),
/// <c>gmCGHeritagePage::ListenToElementMessage @ 0x00483860</c> (the exact
/// button-id -&gt; heritage-id map), <c>gmCGHeritagePage::Update @
/// 0x00483210</c> (description text composition).
/// </summary>
internal sealed class CharacterCreationHeritagePage : IDisposable
{
/// <summary>
/// Button element id -&gt; <c>CharGenState::SetHeritageGroup</c> argument,
/// read verbatim off <c>gmCGHeritagePage::ListenToElementMessage @
/// 0x00483860</c>'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).
/// </summary>
private static readonly IReadOnlyDictionary<uint, uint> HeritageByButtonId =
new Dictionary<uint, uint>
{
[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,
};
/// <summary>
/// <c>ID_CharGen_&lt;Abbrev&gt;Text_BonusSkills_Trained</c> per
/// <c>gmCGHeritagePage::Update</c>'s heritage switch (@0x004833e3):
/// Shadowbound and Penumbraen share the SAME string
/// (<c>case 5: case 0xa:</c>, 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.
/// </summary>
private static readonly IReadOnlyDictionary<uint, string> BonusSkillsKeyByHeritage =
new Dictionary<uint, string>
{
[(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",
};
/// <summary>
/// Root 1d (Campaign CC gate round 1 Batch C): the page's own backdrop
/// element (<c>0x100003be</c>, live-DAT-measured 13 authored states)
/// switches per selected heritage — <c>gmCGHeritagePage::Update
/// @0x00483210</c>'s per-case <c>m_pBackground-&gt;SetState(...)</c>
/// calls (heritages 5/Shadowbound and 10/Penumbraen share literals
/// <c>0x10000058</c>/<c>0x10000059</c> via a shared jump target, every
/// other heritage has its own distinct state).
/// </summary>
private static readonly IReadOnlyDictionary<uint, uint> BackdropStateByHeritage =
new Dictionary<uint, uint>
{
[(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<uint> _onButtonClicked;
private readonly Dictionary<UiButton, uint> _buttons = [];
private readonly UiText? _description;
private readonly UiElement? _backdrop;
private bool _disposed;
/// <param name="onButtonClicked">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 <see cref="Select"/>
/// runs — mirrors retail's message bubbling from
/// <c>gmCGHeritagePage::ListenToElementMessage</c> up to
/// <c>gmCharGenMainUI::ListenToElementMessage</c>'s own tab-restore
/// arm, which is keyed on the same raw id.</param>
internal CharacterCreationHeritagePage(
UiElement pageRoot,
CharacterCreationRuntimeBindings bindings,
Action<uint> 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<DatRichText.Segment> 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<UiText.Line> 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);
}
/// <summary>
/// Campaign CC slice CC6b-MOUNT: AD-101 RETIRED. The Appearance page's
/// real gender buttons (<c>0x100003a7</c>/<c>0x100003a8</c>) 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: <c>CharGenState::
/// Reset @ 0x005C68A0</c> calls <c>SetGender(this, 0)</c> (unset), but
/// <c>gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0</c> calls
/// <c>CharGenState::RandomizeCharacter</c> (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 <c>InitializePage</c>). acdream does not port
/// <c>RandomizeCharacter</c> 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.
/// </summary>
private void Select(uint heritageId)
{
if (_disposed)
return;
_bindings.SelectHeritage(heritageId);
}
/// <summary>
/// Ports <c>gmCGHeritagePage::Update @ 0x00483210</c>'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 <see cref="BonusSkillsKeyByHeritage"/>).
/// Header segments use <c>SetStringInfoWithFont</c>'s own font-index
/// argument (<c>1</c> — palette index 1, live-DAT-measured GREEN);
/// body/bonus-body segments use index <c>0</c> (white). <paramref
/// name="resolveText"/> is the DAT string lookup (<c>RetailUiRuntime</c>'s
/// <c>DatStringResolver</c> over table <c>0x23000002</c>) threaded
/// through the bindings record; a missing resolver or a missing key
/// degrades to skipping that segment rather than throwing.
/// </summary>
private static IReadOnlyList<DatRichText.Segment> ComposeSegments(
UiText description,
IRuntimeCharacterCreationView view,
uint heritageId,
Func<string, string?>? 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<DatRichText.Segment>();
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();
}
}

View file

@ -0,0 +1,383 @@
using System.Globalization;
using AcDream.Core.CharGen;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.UI.Layout;
/// <summary>
/// The Profession page (<c>gmCGProfessionPage</c>, root <c>0x100003d2</c>) —
/// seven template buttons and the six attribute sliders. Decomp anchors:
/// <c>gmCGProfessionPage::InitializePage @ 0x00482d50</c> (slider/display
/// element ids), <c>gmCGProfessionPage::UpdateProfession @ 0x004821b0</c>
/// (template-index -&gt; button-id map, cited on <c>ChargenTemplate</c>),
/// <c>gmCGProfessionPage::UpdateAttributeValues @ 0x00482450</c>
/// (avail/health/stamina/mana display sourcing).
/// </summary>
internal sealed class CharacterCreationProfessionPage : IDisposable
{
/// <summary>Template button id -&gt; template index, verbatim off
/// <c>gmCGProfessionPage::UpdateProfession @ 0x004821b0</c>'s per-case
/// button-highlight dispatch (also the doc comment on
/// <c>ChargenTemplate</c>): 0 is Custom/Adventurer, and the six preset
/// buttons do NOT sit in template-index order.</summary>
private static readonly IReadOnlyDictionary<uint, uint> TemplateByButtonId =
new Dictionary<uint, uint>
{
[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
};
/// <summary>
/// Attribute id -&gt; slider container element id, verbatim off
/// <c>gmCGProfessionPage::InitializePage @ 0x00482d50</c>:
/// <c>m_tSliderArray[N].pAttribField = GetChildRecursive(this,
/// id)</c> for N=1..6 against ids <c>0x100003e6, e7, e9, e8, ea, eb</c>
/// — note the e8/e9 SWAP (id e9 is slider index 3/Quickness, id e8 is
/// slider index 4/Coordination), matching
/// <see cref="ChargenAttributeId"/>'s own documented 3/4 swap.
/// </summary>
private static readonly IReadOnlyDictionary<ChargenAttributeId, uint> SliderContainerByAttribute =
new Dictionary<ChargenAttributeId, uint>
{
[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);
/// <summary>
/// GF-4b: the six slider containers' name-label CHILD, relative id
/// <c>0x100002ed</c> — <c>gmCGProfessionPage::InitializePage
/// @0x00482e1a-0x00482f1d</c> writes <c>CharGenState::GetAttributeName
/// @0x005C3A20</c>'s literal ONCE at page construction (no per-refresh
/// rewrite anywhere in the decomp — <c>UpdateAttributeValues</c> only
/// touches <c>pSlider</c>/<c>pAttribValue</c>, never this id). Live-DAT-
/// measured: this child resolves as Type 1 (<c>UIElement_Button</c>),
/// matching retail's own declared <c>UIElement_Button*</c> field type
/// that still accepts <c>UIElement_Text::SetText</c> — retail's button
/// class carries the same text-rendering capability
/// <see cref="UiButton.Label"/> already is in this port.
/// </summary>
private const uint SliderNameRelativeId = 0x100002EDu;
/// <summary>
/// GF-3: the description textbox — <c>gmCGProfessionPage::InitializePage
/// @0x00483068</c>'s <c>m_pTextBox</c>.
/// </summary>
private const uint DescriptionTextId = 0x100003E0u;
/// <summary>
/// Root 1d: the page's own backdrop (<c>0x100003d8</c>, live-DAT-
/// measured 7 authored states) switches per selected template —
/// <c>gmCGProfessionPage::UpdateProfession @ 0x004821b0</c>'s per-case
/// <c>eax_2-&gt;SetState(...)</c> calls, keyed by <c>ChargenTemplate</c>
/// index (0=Custom..6=Soldier), NOT the button-id map above.
/// </summary>
private static readonly IReadOnlyDictionary<uint, uint> BackdropStateByTemplate =
new Dictionary<uint, uint>
{
[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
};
/// <summary>
/// GF-3: per-template description string id —
/// <c>gmCGProfessionPage::UpdateProfession @0x00482203-0048233d</c>'s
/// per-case <c>var_a4_1</c> literal, resolved through
/// <c>UIElement_Text::SetStringInfo</c> (NOT ...WithFont — a single
/// plain string, no per-run palette color).
/// </summary>
private static readonly IReadOnlyDictionary<uint, string> DescriptionKeyByTemplate =
new Dictionary<uint, string>
{
[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<UiButton, uint> _templateButtons = [];
private readonly Dictionary<ChargenAttributeId, SliderWidgets> _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<UiText.Line> 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);
}
/// <summary>Ports <c>CharGenState::GetAttributeName @ 0x005C3A20</c>
/// verbatim — retail hardcodes these six literals directly (not a
/// DAT/localization lookup), so this port does too.</summary>
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);
}
/// <summary>
/// gmCGProfessionPage::ListenToElementMessage @ 0x004829c0, the
/// scrollbar-drag case (relative id 0x100002ee, idMessage 0xa):
/// <c>ebx = _ftol2(param*100); if (ebx &lt; 0xa) ebx = 0xa;
/// SetAttribValue(this, parent, ebx)</c> — 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).
/// </summary>
private void SetAttributeFromScalar(ChargenAttributeId attribute, float scalar)
{
if (_disposed)
return;
int value = Math.Max(ChargenAttributeMath.AttributeMin, (int)(scalar * 100f));
_bindings.SetAttribute(attribute, value);
}
/// <summary>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.</summary>
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();
}
}

View file

@ -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;
/// <summary>
/// The Skills page (<c>gmCGSkillsPage</c>, root <c>0x100003d3</c>) — now
/// ported to retail's four-bucket sorted insertion model
/// (<c>InsertEntrySorted</c>/<c>UpdateSkillEntry</c>, 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: <c>gmCGSkillsPage::InitializePage @ 0x00481dd0</c> (listbox
/// <c>0x100003f7</c>, credits meter <c>0x100002f3</c> — imports as button
/// <c>0x100003f9</c>'s own consumed Label, see the ctor comment — info
/// panes <c>0x100003fb</c>/<c>0x100003fc</c>),
/// <c>gmCGSkillsPage::UpdateCreditsMeter
/// @ 0x004808f0</c> (credits display is the raw
/// <c>remainingSkillCredits</c> — 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
/// <c>ChargenTableReaderInstalledDatTests</c>) are filtered out via the
/// same two-tier presence check <c>RuntimeCharacterCreationState</c>'s
/// <c>TryGetSkillCost</c> uses.
///
/// <para>
/// <b>GF-5 fix (Campaign CC gate round 1, Batch A, 2026-08-16):</b>
/// <c>RebuildRows</c> used to require <c>Templates[0]</c>'s resolved root to
/// be a <c>UiButton</c> and treat its own Label as the row's whole content —
/// both wrong. Live-DAT-probe-confirmed against the installed EoR dat and
/// <c>gmCGSkillsPage::DoSkillRecords @ 0x004817e0</c>:
/// <c>Templates[0]</c> (<c>0x100002F4</c>, 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 <c>Templates[1]</c>
/// (<c>0x100002FF</c>, a plain container root, 7 children). Byte-traced
/// through <c>DoSkillRecords</c>' own <c>GetChildRecursive</c> calls +
/// <c>tagSkillRecord</c>'s copy-constructor field order
/// (<c>acclient.h</c> struct <c>gmCGSkillsPage::tagSkillRecord</c>):
/// <c>0x10000301</c> = the skill NAME (set once at row build, never
/// refreshed — retail has no per-refresh name write either),
/// <c>0x10000302</c> = <c>pSkillLevelText</c> (the numeric skill SCORE,
/// <c>CharGenState::GetSkillScore</c>), <c>0x10000303</c> =
/// <c>pUpCostText</c>, <c>0x10000306</c> = <c>pDownCostText</c>,
/// <c>0x10000304</c> = <c>pSkillUpButton</c> (fires
/// <c>IncreaseSkillLevel</c> on plain click,
/// <c>ListenToElementMessage @0x004814c0</c> case <c>0x10000304</c>),
/// <c>0x10000305</c> = <c>pSkillDownButton</c> (fires
/// <c>DecreaseSkillLevel</c>, same dispatcher's case <c>0x10000305</c>).
/// 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.
/// </para>
///
/// <para>
/// <b>Batch F fixes (Campaign CC gate round 1, 2026-08-16 — R2-4 + review
/// F1/F2):</b> 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.
/// <list type="bullet">
/// <item>R2-4a (row selection): a row click (or an arrow click, matching
/// retail's own post-Increase/DecreaseSkillLevel <c>SetSelectedItem(...,
/// 1)</c> re-select) now selects that skill — the row's NAME text swaps to
/// <see cref="SelectedNameColor"/> (best-derived "brighter white" per the
/// user's own report + the GF-11b precedent) and the info panes
/// (<c>0x100003fb</c>/<c>0x100003fc</c>) get <c>ShowSkillsText
/// @0x00481250</c>'s title (name + score, <c>" (%d)\n"</c>) and bonus line
/// (<c>"Training Bonus +5"</c>/<c>"Specialization Bonus +10"</c>) — see the
/// closeout paragraph below for the description/formula completion.</item>
/// <item>R2-4c (scrollbar): the listbox's own authored scrollbar link
/// (<see cref="AcDream.App.UI.UiTemplateListBox.ScrollbarElementId"/>, dat
/// property <c>0x72</c>) is now wired to
/// <see cref="AcDream.App.UI.UiTemplateListBox.Scroll"/> — the SAME
/// page-level <c>UiScrollbar.Model</c> linkage every other
/// <c>UiTemplateListBox</c> owner uses (no widget change).</item>
/// <item>Review F1 (cost text): <c>SetSkillText</c>'s Untrained down-cost
/// (<c>@0x00480877</c>) and Specialized up-cost (<c>@0x0048067f</c>) are
/// literal <c>"0"</c>, unconditional — the prior port rendered blank
/// (<c>null</c>) instead. The <c>&lt;0x3e7</c> (999) blank gate exists
/// ONLY on the up-cost at Untrained (<c>@0x00480819</c>) and Trained
/// (<c>@0x0048071f</c>); every down-cost write is unconditional
/// (<c>@0x00480877</c>/<c>@0x00480780</c>/<c>@0x004806c1</c>), including
/// Trained's raw <c>iTrainCost</c> even when it would exceed 999.</item>
/// <item>Review F2 (arrow states): <c>SetSkillText</c> ends every branch
/// driving <c>pSkillUpButton</c>/<c>pSkillDownButton</c> through its own
/// custom Ghosted/Enabled state pair (<see cref="ArrowGhostedStateId"/>/
/// <see cref="ArrowEnabledStateId"/> — raw ids via
/// <see cref="IUiDatStateful.TrySetRetailState"/>, the SAME "authored
/// custom pair" shape as GF-1's Unselected/Selected). Up is gated on
/// <c>remainingSkillCredits</c> 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 <c>bUntrainable</c>/
/// <c>bUnspecializable</c> — re-derived from <c>DoSkillRecords</c>'s own
/// tagSkillRecord build (<c>@0x00480e40</c>-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
/// <see cref="GetCosts"/> — no new data needed.</item>
/// </list>
/// </para>
///
/// <para>
/// <b>Campaign CC gate round 1 closeout (Group 2, 2026-08-16) — AP-213
/// CLOSED, R2-4b implemented:</b> <see cref="AcDream.Core.CharGen.ChargenSkillDetail"/>
/// threads <c>SkillBase.MinLevel</c>/<c>Description</c>/<c>Formula</c> from
/// the global SkillTable through <see cref="AcDream.Core.CharGen.ChargenOptions.TryGetSkillDetail"/>
/// (Content's <c>ChargenTableReader.Project</c> populates it — these three
/// fields have NO per-heritage override in retail, unlike costs). Row
/// building now groups every costable skill into <see cref="SkillBucket"/>
/// (Specialized/Trained/UseableUntrained/UnuseableUntrained,
/// <c>UpdateSkillEntry</c>'s own <c>iMinlevel &lt;= 1</c> useable-vs-
/// unuseable-untrained test) and sorts each bucket's rows alphabetically by
/// name (<c>InsertEntrySorted</c>'s <c>wcscmp</c> compare, ported as
/// <c>string.CompareOrdinal</c>), inserting one <c>Templates[0]</c> header
/// row per bucket (caption child <c>0x100002f6</c>, a <see cref="UiButton"/>
/// per the same <c>UIElement_Button</c>-is-<c>DynamicCast(0xc)</c>-compatible-
/// with-Text quirk GF-4b already used) ahead of that bucket's own
/// <c>Templates[1]</c> skill rows — matching <c>DoSkillRecords</c>'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: <see cref="Refresh"/>
/// detects a bucket change per-row (cheap: <see cref="ComputeBucket"/>
/// against each row's OWN cached <see cref="SkillRow.Bucket"/>) rather than
/// reproducing retail's incremental <c>InsertEntrySorted</c> single-row
/// move — a full <see cref="RebuildRows"/> 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).
/// <see cref="RefreshInfoBox"/>'s own doc covers the description/formula
/// completion.
/// </para>
/// </summary>
internal sealed class CharacterCreationSkillsPage : IDisposable
{
/// <summary>Retail's four skill buckets, in <c>DoSkillRecords</c>'s own
/// build order (Specialized/Trained/UseableUntrained/UnuseableUntrained
/// — top to bottom in the listbox).</summary>
private enum SkillBucket
{
Specialized,
Trained,
UseableUntrained,
UnuseableUntrained,
}
/// <summary>Bucket header row string-table keys, in
/// <see cref="SkillBucket"/> order — <c>DoSkillRecords</c>'
/// <c>compute_str_hash</c> calls (<c>ID_CharGen_Specialized</c> etc.).</summary>
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"),
];
/// <summary><c>UpdateSkillEntry @0x00480bf0</c>'s own bucket test:
/// Specialized(3)/Trained(2) map directly; Untrained/Inactive (every
/// other <see cref="ChargenSkillAdvancementClass"/> value — Inactive is
/// unreachable for any row this page ever lists, since every listed
/// skill is costable and <c>RuntimeCharacterCreationState.ResetSkillLevelsLocked</c>
/// 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 <c>iMinlevel &lt;= 1</c>.</summary>
private static SkillBucket ComputeBucket(ChargenSkillAdvancementClass level, uint minLevel) => level switch
{
ChargenSkillAdvancementClass.Specialized => SkillBucket.Specialized,
ChargenSkillAdvancementClass.Trained => SkillBucket.Trained,
_ => minLevel <= 1 ? SkillBucket.UseableUntrained : SkillBucket.UnuseableUntrained,
};
/// <summary>Retail's own bucket-header caption child
/// (<c>0x100002f6</c>, live-DAT-measured as a <see cref="UiButton"/> —
/// the same <c>UIElement_Button</c>-is-Text-compatible quirk GF-4b
/// already ported).</summary>
private const uint HeaderCaptionElementId = 0x100002F6u;
/// <summary>Retail's own row-name id (set once at row build; retail
/// never re-writes it on refresh either — <c>DoSkillRecords</c>'
/// <c>UIElement_Text::SetText(id_2, &amp;var_138)</c> at
/// <c>0x00481d5d</c> runs OUTSIDE the per-refresh <c>SetSkillText</c>
/// call).</summary>
private const uint RowNameTextId = 0x10000301u;
/// <summary><c>tagSkillRecord::pSkillLevelText</c> — the numeric skill
/// SCORE (<c>SetSkillText @0x00480600</c>'s
/// <c>CharGenState::GetSkillScore</c> call, "%d" format).</summary>
private const uint RowLevelTextId = 0x10000302u;
/// <summary><c>tagSkillRecord::pUpCostText</c>.</summary>
private const uint RowUpCostTextId = 0x10000303u;
/// <summary><c>tagSkillRecord::pDownCostText</c>.</summary>
private const uint RowDownCostTextId = 0x10000306u;
/// <summary><c>tagSkillRecord::pSkillUpButton</c> —
/// <c>ListenToElementMessage</c>'s case <c>0x10000304</c> fires
/// <c>IncreaseSkillLevel</c> on a plain click (<c>idMessage==1</c>).</summary>
private const uint RowUpButtonId = 0x10000304u;
/// <summary><c>tagSkillRecord::pSkillDownButton</c> — same dispatcher's
/// case <c>0x10000305</c> fires <c>DecreaseSkillLevel</c>.</summary>
private const uint RowDownButtonId = 0x10000305u;
/// <summary>Retail's own custom Ghosted state id for
/// <c>pSkillUpButton</c>/<c>pSkillDownButton</c> (<c>SetSkillText</c>'s
/// own <c>SetState(0x1000001a)</c> calls) — distinct from the standard
/// <c>UiButtonStateMachine.Ghosted</c> (13) numbering; the same
/// "authored custom pair, raw retail id" shape as GF-1's
/// Unselected/Selected (<c>0x10000016</c>/<c>0x10000017</c>).</summary>
private const uint ArrowGhostedStateId = 0x1000001Au;
/// <summary>Retail's own custom Enabled state id for the same two
/// buttons (<c>SetState(0x1000001b)</c>).</summary>
private const uint ArrowEnabledStateId = 0x1000001Bu;
/// <summary>R2-4a row-selection highlight: pure white. Re-derived from
/// the GF-11b precedent (list-caption color swap Normal
/// <c>(218,167,85)</c> -&gt; Highlight/white <c>(255,255,255)</c> 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 -&gt; brighter/whiter) is directly evidenced;
/// the exact target RGB is the best available derivation, not a live
/// measurement.</summary>
private static readonly Vector4 SelectedNameColor = Vector4.One;
/// <summary>One built skill row: the resolved <c>Templates[1]</c>
/// subtree plus the child widgets <see cref="RefreshRowValues"/> needs
/// every tick, resolved once at build time rather than re-walked per
/// refresh. <see cref="UnselectedNameColor"/> 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.
/// <see cref="Bucket"/> is the bucket this row was LAST built into —
/// <see cref="Refresh"/> compares it against a fresh
/// <see cref="ComputeBucket"/> call every tick to detect an
/// advance/retreat that needs a re-bucket.</summary>
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<SkillRow> _rows = [];
private uint _lastHeritageId;
private uint? _selectedSkillId;
private bool _rowsBuilt;
private bool _disposed;
internal CharacterCreationSkillsPage(
UiElement pageRoot,
CharacterCreationRuntimeBindings bindings,
Func<uint, uint, UiElement?> 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;
}
}
/// <summary>
/// The gold decorative frame (Type 12, 8 sprite children — the SAME
/// GF-12 corner/edge family) that visually contains BOTH info panes
/// (<c>0x100003fb</c>/<c>0x100003fc</c>) — see the R4-3 clamp above.
/// </summary>
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);
}
/// <summary>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.</summary>
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<SkillBucket, List<(uint SkillId, string Name)>>(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);
}
}
/// <summary>Builds one <c>Templates[0]</c> bucket-header row and writes
/// its caption (<see cref="HeaderCaptionElementId"/>) from the string
/// table — <c>DoSkillRecords</c>'s own unconditional 4-header build,
/// regardless of whether the bucket ends up with any rows.</summary>
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);
}
/// <summary>The up-cost-only 999 blank gate (<c>&lt; 0x3e7</c>,
/// <c>data_794320</c> — an empty <c>PStringBase</c>). Never applied to a
/// down-cost or a literal "0" write — see the per-branch citations in
/// <see cref="RefreshRowValues"/>.</summary>
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)];
/// <summary>Same dictionary-presence gate as
/// <c>RuntimeCharacterCreationState.TryGetSkillCost</c> — heritage list
/// first, global SkillTable fallback.</summary>
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);
}
/// <summary><c>pSkillUpButton</c> click: <c>IncreaseSkillLevel
/// @0x00480ca0</c> — Untrained/Inactive -&gt; Trained,
/// Trained -&gt; Specialized.</summary>
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);
}
/// <summary><c>pSkillDownButton</c> click: <c>DecreaseSkillLevel
/// @0x00480d60</c> — Specialized -&gt; Trained,
/// Trained -&gt; Untrained.</summary>
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);
}
/// <summary>
/// R2-4a: row click / arrow click selection — the port's equivalent of
/// retail's listbox-level <c>SetSelectedItem</c> notification (see
/// <see cref="RebuildRows"/>'s own wiring doc). Applies the highlight
/// to every row (so the PREVIOUSLY selected row also gets restored to
/// its own <see cref="SkillRow.UnselectedNameColor"/>) and refreshes
/// the info panes for the newly selected skill. <see cref="_bindings"/>'
/// <c>View</c> is resolved fresh here, never cached, per
/// <c>feedback_resolve_deferred_funcs_per_call.md</c>.
/// </summary>
private void SelectRow(uint skillId)
{
if (_disposed)
return;
_selectedSkillId = skillId;
ApplySelectionHighlight();
if (_bindings.View() is { } view)
RefreshInfoBox(view, view.Snapshot);
}
/// <summary>Applies <see cref="SelectedNameColor"/>/<see cref="SkillRow.UnselectedNameColor"/>
/// to every row's name text based on <see cref="_selectedSkillId"/> —
/// factored out of <see cref="SelectRow"/> so <see cref="Refresh"/> 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).</summary>
private void ApplySelectionHighlight()
{
foreach (SkillRow row in _rows)
{
if (row.NameText is { } nameText)
nameText.DefaultColor = row.SkillId == _selectedSkillId ? SelectedNameColor : row.UnselectedNameColor;
}
}
/// <summary>
/// <c>gmCGSkillsPage::ShowSkillsText @0x00481250</c> — writes
/// <c>m_pInfoBoxTitle</c> (<c>0x100003fb</c>) and <c>m_pInfoBoxText</c>
/// (<c>0x100003fc</c>) for the currently selected skill, or clears both
/// when nothing is selected (retail's own <c>arg2==0</c>/lookup-miss
/// arms, both <c>UIElement_Text::ClearAllText</c>). Title is the skill
/// name plus its current score (<c>" (%d)\n"</c>, e.g. "Loyalty (5)").
/// Body is: DESCRIPTION, then level-gated bonus text
/// (<c>"Training Bonus +5"</c>/<c>"Specialization Bonus +10"</c> —
/// TWO spaces before the number, matching the compiled literal
/// verbatim), then <see cref="ComposeFormula"/>'s "Formula : ..." line —
/// <c>eax_2[7]</c>/<c>eax_2[8]</c> off the row's cached
/// <c>tagSkillRecord</c>, byte-traced against <c>tagSkillRecord</c>'s
/// own field order (<c>acclient.h</c>). Routed through
/// <see cref="DatRichText.Compose"/> (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
/// <see cref="Refresh"/>'s caller detects a revision change) and handed
/// to <see cref="UiText.LinesProvider"/> as a closed-over, already-built
/// list, matching the F11 no-per-frame-recompute discipline.
///
/// <para>
/// <b>Group 2 closeout (Campaign CC gate round 1):</b> DESCRIPTION is a
/// byte-verified port (<c>SkillBase._description</c>, read directly off
/// the DAT) and the ONLY segment routed through
/// <see cref="DatRichText.Compose"/>'s word-wrap — description text can
/// run arbitrarily long, unlike the bonus/formula lines below. The bonus
/// line (<c>"Training Bonus +5"</c>/<c>"Specialization Bonus +10"</c>,
/// TWO spaces before the number, matching the compiled literal
/// verbatim) and <see cref="ComposeFormula"/>'s result are each added as
/// their OWN single, UNWRAPPED <see cref="UiText.Line"/> — deliberately
/// bypassing <c>DatRichText.Compose</c> for these two, since its
/// word-splitting wrap (<see cref="UiText.WrapWords"/>) collapses
/// consecutive spaces when it rejoins tokens, which would silently
/// mangle the bonus line's own authored double-space formatting (caught
/// by <c>SkillsPage_ArrowClick_AlsoSelectsRow_InfoBoxShowsLevelBonusLine</c>
/// 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). <see cref="ComposeFormula"/>'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.
/// </para>
/// </summary>
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<UiText.Line>();
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;
}
}
/// <summary>
/// <c>gmCGSkillsPage::MakeSkillFormula @0x00480e10</c> — retail's
/// formula-text composition. HIGH CONFIDENCE (directly read from
/// compiled string literals plus the field layout
/// <see cref="AcDream.Core.CharGen.ChargenSkillFormula"/> shares with
/// the DatReaderWriter binding's own <c>SkillFormula</c> struct): the
/// <c>"Formula : "</c> prefix, the per-attribute <c>"(%u x %s)"</c>-vs-
/// bare-name choice (a term's own multiplier <c>&gt; 1</c> gets the
/// parenthesized multiply form, else just the attribute's name — the
/// exact <c>eax_6 &lt;= 1</c>/<c>ebx_3 &lt;= 1</c> gate), the
/// <c>" / %u"</c> divisor suffix (gated on <c>Divisor != 1</c>, the
/// exact <c>__saved_ebp_11 != 1</c> gate), and the <c>" +%u"</c>
/// additive-bonus suffix (gated on <c>AdditiveBonus != 0</c>, the exact
/// <c>__saved_ebp_12 != 0</c> gate).
///
/// <para>
/// <b>LOWER CONFIDENCE, disclosed rather than silently guessed
/// (register AP-231):</b> the connector text between a two-attribute
/// formula's own two terms. This port renders <c>" + "</c> — the
/// well-known "(Attr1 + Attr2) / N" shape most published AC skill
/// formulas use — but the decompiled function's own two candidate
/// connector literals (<c>data_7a01a4</c>, appended between the terms;
/// <c>data_797584</c>, 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 <c>PStringBase</c>
/// appends whose actual wide-character content Binary Ninja's HLIL does
/// not surface as a literal, and the surrounding control flow (a
/// <c>goto</c>-based re-convergence between the single-attribute and
/// dual-attribute code paths) left <c>data_797584</c>'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
/// <c>"Formula : (2 x Strength) + Endurance / 4 +2"</c>-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.
/// </para>
/// </summary>
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);
}
/// <summary>Ports <c>CharGenState::GetAttributeName @ 0x005C3A20</c>
/// verbatim — retail hardcodes these six literals directly (not a
/// DAT/localization lookup). Duplicated locally from
/// <c>CharacterCreationProfessionPage</c>'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.</summary>
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;
}
}

View file

@ -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;
/// <summary>
/// The Summary page (<c>gmCGSummaryPage</c>, root <c>0x100003d6</c>) —
/// Campaign CC slice CC5, retiring the TS-82 content-inert placeholder.
/// Decomp anchors: <c>gmCGSummaryPage::InitializePage @ 0x0047bbf0</c>
/// (widget ids, its OWN <c>gmCG3DView</c> instance, camera set + 180°
/// heading + <c>StartAnimation</c> — a live idle-animated preview, not a
/// static frozen frame), <c>::SetSummaryText @ 0x0047b1d0</c> (the listbox's
/// three-template row content: template 0 = a single <c>UiText</c> line,
/// template 1 = a category-header <c>UiText</c>, template 2 = a two-column
/// key/value <c>UiText</c> pair — live-DAT-probe-confirmed against the
/// installed EoR dat, resolving DID <c>0x2100004C</c> elements
/// <c>0x100002F8/FA/FB</c>), <c>::ListenToElementMessage @ 0x0047bf40</c>
/// (the name field's commit-on-idMessage-0x12-or-0x44 dispatch, the
/// &gt;32-char <c>ID_CharGen_NameTooLong</c> reject-and-revert path — see
/// <see cref="CommitNameFromField"/>'s own doc comment for the 32-vs-33
/// reconciliation), <c>::DoNameLimitDialog @ 0x0047bd80</c>.
///
/// <para>
/// <b>Listbox content scope cut (register-worthy, AP-213's own precedent):</b>
/// 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
/// <see cref="CharacterCreationProfessionPage"/>'s own already-cited
/// <c>UpdateAttributeValues @ 0x00482450</c> formulas (Health=Endurance/2,
/// Stamina=Endurance, Mana=Self) rather than this page's OWN
/// <c>SetSummaryText</c> 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).
/// </para>
/// </summary>
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;
/// <summary>Row-template child ids, live-DAT-probe-confirmed:
/// template 0's single line, template 1's header line, template 2's
/// key/value pair.</summary>
private const uint SingleLineTextId = 0x100002F9u;
private const uint HeaderTextId = 0x100000FEu;
private const uint KeyTextId = 0x100002FCu;
private const uint ValueTextId = 0x100002FDu;
/// <summary>
/// Retail's <c>name[33]</c> buffer (32 usable chars + null terminator —
/// <c>RuntimeCharacterCreationState.TrySetName</c>'s own
/// already-established storage cap). Review fix round F6 (2026-08-16):
/// the decompiled UI-side check at <c>ListenToElementMessage @
/// 0x0047bf40</c> (<c>~0x0047bfd1</c>) compares the field text's
/// <c>m_charbuffer</c> LENGTH FIELD against the literal <c>0x21</c>
/// (33) — that field is confirmed NUL-INCLUSIVE (the SAME method's own
/// empty-field check earlier at <c>0x0047bf93</c> compares that field to
/// <c>1</c>, i.e. an empty string's length reads as 1, not 0). So
/// <c>length &gt; 33</c> is EXACTLY <c>visibleChars &gt; 32</c>: a
/// 32-character name has length 33 (not <c>&gt; 33</c>, accepted), a
/// 33-character name has length 34 (<c>&gt; 33</c>, 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.
/// </summary>
private const int MaxNameLength = 32;
/// <summary>
/// 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
/// (<c>0x100002e7</c>), distinct from <see cref="ScrollId"/> (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.
/// </summary>
private const uint HowToScrollRelativeId = 0x100002E7u;
/// <summary>
/// Heritage id -&gt; (male name-list key, female name-list key) per
/// <c>gmCGSummaryPage::SetHowToText @0x0047ae20</c>'s switch
/// (@0x0047aeb2-0x0047afda). ONLY heritages 1-4 (Aluvian/Gharundim/
/// Sho/Viamontian) resolve to real string literals
/// (<c>"ID_CharGen_&lt;Abbrev&gt;{Male,Female}Names"</c>, 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
/// <c>BonusSkillsKeyByHeritage</c> table already documents — meaning
/// no real name-suggestion string exists for those heritages; this
/// port does not invent one.
/// </summary>
private static readonly IReadOnlyDictionary<uint, (string Male, string Female)> NameSuggestionKeysByHeritage =
new Dictionary<uint, (string, string)>
{
[(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;
/// <summary>Late-bound preview control seam — see
/// <see cref="IChargenPreviewControl"/>'s own doc comment for why this
/// page cannot receive the real renderer at construction time.</summary>
internal IChargenPreviewControl? PreviewControl { get; set; }
/// <summary>The authored viewport (<c>0x10000406</c>) — Summary's OWN
/// <c>gmCG3DView</c> instance, distinct from the Appearance page's.</summary>
internal UiViewport? Viewport { get; }
internal CharacterCreationSummaryPage(
UiElement pageRoot,
CharacterCreationRuntimeBindings bindings,
RetailDialogFactory dialogs,
string nameTooLongMessage,
Func<uint, uint, UiElement?> 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);
}
/// <summary>
/// Commit 3 (Campaign CC gate round 1 Batch C): ports
/// <c>gmCGSummaryPage::SetHowToText @0x0047ae20</c>. Retail
/// concatenates <c>ID_CharGen_SummaryHowTo</c> +
/// (heritage/gender-specific name-suggestion list, heritages 1-4
/// only) + <c>ID_CharGen_SummaryHowToEnd</c> directly
/// (<c>append_n_chars</c>, no separator literal) into ONE plain
/// <c>UIElement_Text::SetText</c> — no per-run font/color argument,
/// unlike Heritage's <c>...WithFont</c> calls, so this is a single
/// <see cref="UiText.DefaultColor"/> segment.
/// </summary>
private void RefreshHowToText(RuntimeCharacterCreationSnapshot snapshot)
{
if (_howToText is null)
return;
Func<string, string?>? 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<UiText.Line> composedLines = DatRichText.Compose(_howToText, segments);
_howToText.LinesProvider = () => composedLines;
}
// ── Name field (ListenToElementMessage @ 0x0047bf40) ────────────────
/// <summary>
/// Review fix round F9 (2026-08-16), empty-name commit: retail's
/// <c>ListenToElementMessage @ ~0x0047bf93</c> 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 <c>SetName</c> — behind <c>if (length != 1)</c>. Blurring
/// an EMPTIED field in retail therefore leaves <c>CharGenState.name</c>
/// UNCHANGED (whatever it held before), not cleared; <c>DoFinish</c>
/// 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 <see cref="CharacterCreationRuntimeBindings.SetName"/>
/// (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
/// <see cref="Refresh"/>'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 <c>field.Text ("") != snapshot.Name (the stale unchanged
/// name)</c> 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.
/// </summary>
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;
});
}
/// <summary>Ports <c>NameInputFilter @ 0x004663b0</c> exactly: ASCII
/// letters (<c>isalpha</c>), space (<c>0x20</c>), apostrophe
/// (<c>0x27</c>), or hyphen (<c>0x2d</c>).</summary>
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;
}
/// <summary>
/// Review fix round F3 (2026-08-16), byte-decoded against
/// <c>SetSummaryText @ ~0x0047b6be-0x0047b9e0</c>: retail adds each
/// bucket's HEADER row UNCONDITIONALLY, before it ever walks
/// <c>skillRecordList</c> 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, <c>AddItemFromTemplateList(..., 2, ...)</c> @
/// <c>0x0047b938</c>), not template 0's single line — KEY = the skill
/// name, VALUE = <c>CharGenState::GetSkillScore(state, skill->id)</c> @
/// <c>0x0047b923</c>, ported as <see cref="CharacterCreationRuntimeBindings.GetSkillScore"/>.
/// </summary>
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;
}
}

View file

@ -0,0 +1,171 @@
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.UI.Layout;
/// <summary>
/// The Town page (<c>gmCGTownPage</c>, root <c>0x100003d5</c>) — the four
/// starting-area buttons. Decomp anchors:
/// <c>gmCGTownPage::InitializePage @ 0x0047c6d0</c> (button ids),
/// <c>gmCGTownPage::SetTown @ 0x0047c360</c> (button -&gt;
/// <c>CharGenState::SetStartArea(arg2 - 1)</c> literal index map: Holtburg
/// -&gt; 0, Shoushi -&gt; 1, Yaraq -&gt; 2, Sanamar -&gt; 3),
/// <c>gmCGTownPage::ListenToElementMessage @ 0x0047c480</c> (Sanamar's
/// <c>AccountHasThroneOfDestiny</c> 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), <c>gmCGTownPage::SetTownString @
/// 0x0047c1f0</c> (composed description text).
/// </summary>
internal sealed class CharacterCreationTownPage : IDisposable
{
/// <summary>Button element id -&gt; the LITERAL <c>startArea</c> index
/// <c>gmCGTownPage::SetTown</c> sends — retail hardcodes these four
/// indices directly rather than looking them up by name, so this port
/// does too.</summary>
private static readonly IReadOnlyDictionary<uint, int> StartAreaByButtonId =
new Dictionary<uint, int>
{
[0x1000040Du] = 0, // Holtburg
[0x1000040Fu] = 1, // Shoushi
[0x1000040Eu] = 2, // Yaraq
[0x1000040Bu] = 3, // Sanamar (ToD-gated in retail; see class doc)
};
private static readonly IReadOnlyDictionary<int, string> TownTextKeyByStartArea =
new Dictionary<int, string>
{
[0] = "ID_CharGen_HoltText",
[1] = "ID_CharGen_ShoushiText",
[2] = "ID_CharGen_YaraqText",
[3] = "ID_CharGen_SanamarText",
};
/// <summary>
/// Start-area index -&gt; the page's OWN retail state literal — a
/// SEPARATE state machine from <c>CharacterCreationUiController</c>'s
/// master-page per-page-index cycling
/// (<c>0x10000025 + (page - 1)</c>). <c>gmCGTownPage::SetTown @
/// 0x0047c360</c> calls <c>this-&gt;vtable-&gt;SetState(...)</c> (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-&gt;0x10000034,
/// Shoushi-&gt;0x10000037, Yaraq-&gt;0x10000036, Sanamar-&gt;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.
/// </summary>
private static readonly IReadOnlyDictionary<int, uint> PageStateByStartArea =
new Dictionary<int, uint>
{
[0] = 0x10000034u, // Holtburg
[1] = 0x10000037u, // Shoushi
[2] = 0x10000036u, // Yaraq
[3] = 0x10000035u, // Sanamar
};
private readonly CharacterCreationRuntimeBindings _bindings;
private readonly UiElement _pageRoot;
private readonly Dictionary<UiButton, int> _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<UiText.Line> 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<string, string?>? 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();
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,99 @@
namespace AcDream.App.UI.Layout;
internal sealed record CharacterCreationUiMountResources(
uint LayoutId,
ImportedLayout Layout,
Func<uint, uint, UiElement?> TemplateResolver,
CharacterCreationUiController.DialogStrings Strings);
/// <summary>
/// Retryable, idempotent composition edge for the character-creation screen —
/// clone of <see cref="CharacterManagementUiMountCoordinator"/>'s recipe. DATs
/// can become readable after the graphical runtime starts, so an unavailable
/// dialog catalog, root, or string must not permanently suppress the screen.
/// </summary>
internal sealed class CharacterCreationUiMountCoordinator : IDisposable
{
private readonly UiRoot _host;
private readonly CharacterCreationRuntimeBindings _bindings;
private readonly Func<RetailDialogFactory?> _ensureDialogs;
private readonly Func<CharacterCreationUiMountResources?> _loadResources;
private bool _disposed;
public CharacterCreationUiMountCoordinator(
UiRoot host,
CharacterCreationRuntimeBindings bindings,
Func<RetailDialogFactory?> ensureDialogs,
Func<CharacterCreationUiMountResources?> loadResources)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
_ensureDialogs = ensureDialogs
?? throw new ArgumentNullException(nameof(ensureDialogs));
_loadResources = loadResources
?? throw new ArgumentNullException(nameof(loadResources));
}
public CharacterCreationUiController? Controller { get; private set; }
public void Tick()
{
if (_disposed || Controller is not null)
return;
try
{
RetailDialogFactory? dialogs = _ensureDialogs();
if (dialogs is null)
return;
CharacterCreationUiMountResources? resources = _loadResources();
if (resources is null)
return;
CharacterCreationUiController? candidate =
CharacterCreationUiController.CreateDetached(
_host,
resources.Layout,
resources.TemplateResolver,
dialogs,
_bindings,
resources.Strings);
if (candidate is null)
return;
Controller = candidate;
candidate.AttachAndTick();
Console.WriteLine(
$"[UI] retail character creation from enum table 5 "
+ $"(0x10000039 -> 0x{resources.LayoutId:X8}, root 0x100003CC).");
}
catch (Exception error)
{
CharacterCreationUiController? partial = Controller;
Controller = null;
try
{
partial?.Dispose();
}
catch (Exception cleanupError)
{
Console.WriteLine(
"[UI] character creation partial-mount cleanup failed: "
+ cleanupError.Message);
}
Console.WriteLine(
"[UI] character creation mount will retry after resource "
+ $"recovery: {error.Message}");
}
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
Controller?.Dispose();
Controller = null;
}
}

View file

@ -0,0 +1,828 @@
using System.Numerics;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Projects Runtime's one borrowed pre-world character-selection owner through
/// retail <c>gmCharacterManagementUI</c>'s authored retained layout. The list is
/// intentionally flat: the retail class owns no viewport or model preview.
/// </summary>
internal sealed class CharacterManagementUiController : IDisposable
{
internal const uint RootEnum = 0x10000005u;
internal const uint RootElementId = 0x1000039Au;
internal const uint WorldTextElementId = 0x1000039Bu;
internal const uint ListElementId = 0x1000039Du;
internal const uint CreateElementId = 0x100003A0u;
internal const uint EnterElementId = 0x100003A2u;
internal const uint DeleteElementId = 0x1000039Fu;
internal const uint RestoreElementId = 0x1000039Eu;
/// <summary>
/// gmCharacterManagementUI::ListenToElementMessage@0x004ed5a0's element-id
/// switch is keyed off <c>idElement - 0x1000039d</c> (the listbox base);
/// offset 6 -> QueueUIMode(0x10000005), the mode gmCreditsUI registers
/// (Register@0x0047a69e) — out of scope this round (finding 1 note).
/// </summary>
internal const uint CreditsElementId = 0x100003A3u;
/// <summary>Offset 7 from the listbox base -> MakeConfirmExitDialog@0x004ed250.</summary>
internal const uint ExitElementId = 0x100003A4u;
internal sealed record DialogStrings(
Func<string, string> DeleteConfirmation,
string DeleteResponse,
string PleaseWait,
string EnteringWorld,
/// <summary>
/// Retail <c>ID_CharacterManagement_ConfirmExit</c> (table
/// <c>0x23000002</c>) — "Are you sure you want to leave?", the text
/// <c>MakeConfirmExitDialog@0x004ed250</c> resolves via
/// <c>StringInfo::SetStringIDandTableEnum(compute_str_hash(
/// "ID_CharacterManagement_ConfirmExit"), 0x10000002)</c>.
/// </summary>
string ConfirmExit);
private readonly UiRoot _host;
private readonly ImportedLayout _layout;
private readonly UiText _worldText;
private readonly UiTemplateListBox _list;
private readonly UiButton _create;
private readonly UiButton _enter;
private readonly UiButton _delete;
private readonly UiButton _restore;
private readonly UiButton _credits;
private readonly UiButton _exit;
private readonly RetailDialogFactory _dialogs;
private readonly CharacterSelectionRuntimeBindings _bindings;
private readonly DialogStrings _strings;
private readonly List<UiButton> _rows = [];
private readonly Dictionary<UiButton, uint> _rowIds = [];
private Vector2 _authoredCanvas;
private RuntimeGenerationToken _lastGeneration;
private long _lastRevision = long.MinValue;
private string _lastWorldName = string.Empty;
private uint _deleteDialogContext;
private uint _operationWaitContext;
private uint _enterWaitContext;
private uint _errorDialogContext;
private uint _confirmExitDialogContext;
private bool _active;
private bool _restoreCommandInFlight;
private bool _suppressDialogCallbacks;
private bool _disposed;
private CharacterManagementUiController(
UiRoot host,
ImportedLayout layout,
UiText worldText,
UiTemplateListBox list,
UiButton create,
UiButton enter,
UiButton delete,
UiButton restore,
UiButton credits,
UiButton exit,
RetailDialogFactory dialogs,
CharacterSelectionRuntimeBindings bindings,
DialogStrings strings)
{
_host = host;
_layout = layout;
_worldText = worldText;
_list = list;
_create = create;
_enter = enter;
_delete = delete;
_restore = restore;
_credits = credits;
_exit = exit;
_dialogs = dialogs;
_bindings = bindings;
_strings = strings;
Root.Left = 0f;
Root.Top = 0f;
Root.ClickThrough = false;
Root.Visible = false;
// Campaign LA gate round 2 (register AD-98): the root KEEPS its authored
// 800×600 extent — retail never resizes it (zero edge anchors, verified
// against the installed DAT) and its blitter has no stretch mode; the
// whole composed screen stretches once at presentation. Our equivalent:
// while this screen is active, the host stretches the ENTIRE canvas —
// widgets, glyphs, and the painted background (which carries the
// "World"/"Characters" captions as art) — as one unit via
// UiRoot.FixedCanvasSize, declared/revoked through the owner-scoped
// arbiter (review fix round R1, 2026-08-15) rather than written
// directly — character-creation can be simultaneously active on top
// of this screen, and a raw write from either controller is a last-
// writer-wins race with no owner. Resizing the root here instead of
// using the canvas is exactly the half-substitution that misaligned
// the widgets against the stretched art at the 2026-08-15 user gate.
_authoredCanvas = new Vector2(
Root.Width > 0f ? Root.Width : 800f,
Root.Height > 0f ? Root.Height : 600f);
// Campaign CC slice CC7: gmCharacterManagementUI::ListenToElementMessage
// @ 0x004ed5a0 case 3 dispatches Create unconditionally on click
// (QueueUIMode(0x1000000b) — no gate at click time); the gate lives
// entirely in UpdateButtons @ 0x004ec240's own Enabled/ghosted state
// (see ApplyButtons below), so the click handler is wired once here
// and Enabled tracks the borrowed snapshot every tick. Starts
// disabled/ghosted until the first real snapshot arrives.
_create.Visible = true;
_create.Enabled = false;
_create.OnClick = RequestCreate;
_enter.OnClick = EnterSelected;
_delete.OnClick = RequestDelete;
_restore.OnClick = RestoreSelected;
// Credits (retail QueueUIMode(0x10000005) -> gmCreditsUI) is out of
// scope this round (finding 1 note) — same "future campaign, visibly
// ghosted, no invented action" treatment as Create above. Filed as
// issue #400.
_credits.Visible = true;
_credits.Enabled = false;
_credits.OnClick = null;
_exit.OnClick = RequestExit;
// World name (retail UpdateWorldName@0x004ec120 /
// RecvNotice_WorldName@0x004ec360 both just push
// Client::GetWorldName() onto this element). LinesProvider reads the
// live field Tick() updates each time Runtime's snapshot changes.
_worldText.LinesProvider =
() => [new UiText.Line(_lastWorldName, _worldText.DefaultColor)];
}
internal UiElement Root => _layout.Root;
internal IReadOnlyList<UiButton> Rows => _rows;
internal uint DeleteDialogContext => _deleteDialogContext;
internal uint OperationWaitContext => _operationWaitContext;
internal uint EnterWaitContext => _enterWaitContext;
internal uint ErrorDialogContext => _errorDialogContext;
internal uint ConfirmExitDialogContext => _confirmExitDialogContext;
internal void ResetSession()
{
if (_disposed)
return;
Deactivate();
_lastRevision = long.MinValue;
}
internal static CharacterManagementUiController? Bind(
UiRoot host,
ImportedLayout layout,
Func<uint, uint, UiElement?> templateResolver,
RetailDialogFactory dialogs,
CharacterSelectionRuntimeBindings bindings,
DialogStrings strings)
{
CharacterManagementUiController? controller = CreateDetached(
host,
layout,
templateResolver,
dialogs,
bindings,
strings);
if (controller is null)
return null;
try
{
controller.AttachAndTick();
return controller;
}
catch
{
controller.Dispose();
throw;
}
}
internal static CharacterManagementUiController? CreateDetached(
UiRoot host,
ImportedLayout layout,
Func<uint, uint, UiElement?> templateResolver,
RetailDialogFactory dialogs,
CharacterSelectionRuntimeBindings bindings,
DialogStrings strings)
{
ArgumentNullException.ThrowIfNull(host);
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(templateResolver);
ArgumentNullException.ThrowIfNull(dialogs);
ArgumentNullException.ThrowIfNull(bindings);
ArgumentNullException.ThrowIfNull(strings);
if (ContainsViewport(layout.Root))
{
Console.WriteLine(
"[UI] character management: refusing an unapproved model-preview viewport.");
return null;
}
if (layout.Root.DatElementId != RootElementId
|| layout.FindElement(WorldTextElementId) is not UiText worldText
|| layout.FindElement(ListElementId) is not UiTemplateListBox list
|| layout.FindElement(CreateElementId) is not UiButton create
|| layout.FindElement(EnterElementId) is not UiButton enter
|| layout.FindElement(DeleteElementId) is not UiButton delete
|| layout.FindElement(RestoreElementId) is not UiButton restore
|| layout.FindElement(CreditsElementId) is not UiButton credits
|| layout.FindElement(ExitElementId) is not UiButton exit)
{
Console.WriteLine(
"[UI] character management: the authored root/list/button contract is incomplete.");
return null;
}
list.TemplateResolver = templateResolver;
try
{
return new CharacterManagementUiController(
host,
layout,
worldText,
list,
create,
enter,
delete,
restore,
credits,
exit,
dialogs,
bindings,
strings);
}
catch
{
list.TemplateResolver = null;
create.OnClick = null;
enter.OnClick = null;
delete.OnClick = null;
restore.OnClick = null;
exit.OnClick = null;
throw;
}
}
internal void AttachAndTick()
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (Root.Parent is null)
_host.AddChild(Root);
Tick();
}
private static bool ContainsViewport(UiElement element)
{
if (element is UiViewport)
return true;
foreach (UiElement child in element.Children)
if (ContainsViewport(child))
return true;
return false;
}
internal void Tick()
{
if (_disposed)
return;
IRuntimeCharacterSelectionView? view = _bindings.View();
RuntimeCharacterSelectionSnapshot snapshot = view?.Snapshot ?? default;
if (view is null || !snapshot.IsActive)
{
Deactivate();
_lastGeneration = snapshot.Generation;
_lastRevision = snapshot.Revision;
return;
}
if (!_active)
{
_active = true;
Root.Visible = true;
_host.DeclareFixedCanvas(this, _authoredCanvas);
_host.BringToFront(Root);
}
// World name rides independently of the roster revision gate below —
// ServerName can arrive slightly before or after CharacterList (see
// RuntimeCharacterSelectionState.ApplyWorldName).
_lastWorldName = snapshot.WorldName;
if (_lastGeneration != snapshot.Generation
|| _lastRevision != snapshot.Revision)
{
if (TryCaptureRoster(view, snapshot, out RuntimeCharacterSelectionEntry[] roster))
{
bool rowsReady;
if (RowsMatchRoster(roster, snapshot.SlotCount))
{
ApplyHighlight(snapshot.HighlightedCharacterId);
rowsReady = true;
}
else
{
rowsReady = RebuildRows(
roster,
snapshot.SlotCount,
snapshot.HighlightedCharacterId);
}
if (rowsReady)
{
_lastGeneration = snapshot.Generation;
_lastRevision = snapshot.Revision;
}
}
else
{
// A receive-thread roster/reset raced the borrowed snapshot.
// Leave the revision unconsumed so the next frame retries from
// one coherent view; never present a partially mixed roster.
_lastRevision = long.MinValue;
snapshot = view.Snapshot;
if (!snapshot.IsActive)
{
Deactivate();
_lastGeneration = snapshot.Generation;
_lastRevision = snapshot.Revision;
return;
}
}
}
else
{
ApplyHighlight(snapshot.HighlightedCharacterId);
}
ApplyButtons(snapshot.Buttons);
ReconcileDialogs(view, snapshot);
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
try
{
CloseAllDialogs(suppressCallbacks: true);
}
finally
{
_host.RevokeFixedCanvas(this);
_enter.OnClick = null;
_delete.OnClick = null;
_restore.OnClick = null;
_exit.OnClick = null;
foreach (UiButton row in _rows)
{
row.OnClick = null;
row.OnDoubleClick = null;
}
_rows.Clear();
_rowIds.Clear();
_list.Flush();
_list.TemplateResolver = null;
_host.RemoveChild(Root);
}
}
private static bool TryCaptureRoster(
IRuntimeCharacterSelectionView view,
RuntimeCharacterSelectionSnapshot expected,
out RuntimeCharacterSelectionEntry[] roster)
{
roster = new RuntimeCharacterSelectionEntry[expected.RosterCount];
for (int i = 0; i < roster.Length; i++)
{
if (!view.TryGetAt(i, out roster[i]))
return false;
}
RuntimeCharacterSelectionSnapshot after = view.Snapshot;
return after.Generation == expected.Generation
&& after.Revision == expected.Revision
&& after.RosterCount == expected.RosterCount;
}
private bool RowsMatchRoster(
IReadOnlyList<RuntimeCharacterSelectionEntry> roster,
int allowedSlotCount)
{
if (_rows.Count != roster.Count)
return false;
int rowHeight = ComputeRowHeight(
_list.Height,
roster.Count,
allowedSlotCount);
for (int i = 0; i < roster.Count; i++)
{
UiButton row = _rows[i];
RuntimeCharacterSelectionEntry character = roster[i];
if (!_rowIds.TryGetValue(row, out uint characterId)
|| characterId != character.CharacterId
|| !string.Equals(row.Label, character.Name, StringComparison.Ordinal)
|| (int)row.Height != rowHeight
|| row.LabelColor != (character.IsPendingDelete
? new Vector4(1f, 0f, 0f, 1f)
: Vector4.One))
{
return false;
}
}
return true;
}
private bool RebuildRows(
IReadOnlyList<RuntimeCharacterSelectionEntry> roster,
int allowedSlotCount,
uint highlightedCharacterId)
{
foreach (UiButton row in _rows)
{
row.OnClick = null;
row.OnDoubleClick = null;
}
_rows.Clear();
_rowIds.Clear();
_list.Flush();
int rowHeight = ComputeRowHeight(
_list.Height,
roster.Count,
allowedSlotCount);
_list.LineHeight = rowHeight;
bool complete = _list.Templates.Count > 0
&& _list.TemplateResolver is not null;
foreach (RuntimeCharacterSelectionEntry character in roster)
{
if (!complete)
break;
UiTemplateListEntry template = _list.Templates[0];
if (_list.TemplateResolver!(
template.TemplateLayoutId,
template.TemplateElementId) is not UiButton row)
{
complete = false;
break;
}
// AddItemFromTemplateList creates the same template, but its
// retained viewport stacks at the template's authored 16px
// height. Retail establishes the computed size on every row; our
// list fixes Top during insertion, so build and resize first to
// make every subsequent Top exact.
row.Height = rowHeight;
_list.AddPrebuiltRow(row);
uint characterId = character.CharacterId;
row.Label = character.Name;
row.LabelColor = character.IsPendingDelete
? new Vector4(1f, 0f, 0f, 1f)
: Vector4.One;
row.Enabled = true;
row.SuppressSelfToggle = true;
row.Selected = characterId == highlightedCharacterId;
row.OnClick = () => Highlight(characterId);
row.OnDoubleClick = EnterSelected;
_rows.Add(row);
_rowIds.Add(row, characterId);
}
if (complete)
return true;
foreach (UiButton row in _rows)
{
row.OnClick = null;
row.OnDoubleClick = null;
}
_rows.Clear();
_rowIds.Clear();
_list.Flush();
_lastRevision = long.MinValue;
return false;
}
internal static int ComputeRowHeight(
float listHeight,
int rosterCount,
int allowedSlotCount)
{
// RebuildCharacterList @ 0x004EC3A0 uses integer UIRegion height and
// signed integer division for both terms. The 0x66666667 multiply/
// shift sequence is compiler output for height / 10.
int height = (int)MathF.Truncate(listHeight);
int denominator = Math.Max(rosterCount, allowedSlotCount);
if (denominator <= 0)
return height / 10;
return Math.Max(height / denominator, height / 10);
}
private void ApplyHighlight(uint highlightedCharacterId)
{
foreach (UiButton row in _rows)
row.Selected = _rowIds.TryGetValue(row, out uint characterId)
&& characterId == highlightedCharacterId;
}
private void ApplyButtons(RuntimeCharacterSelectionButtons buttons)
{
_create.Visible = true;
_create.Enabled = buttons.CanCreate;
_enter.Enabled = buttons.CanEnter;
_delete.Visible = buttons.DeleteVisible;
_delete.Enabled = buttons.CanDelete;
_restore.Visible = buttons.RestoreVisible;
_restore.Enabled = buttons.CanRestore;
}
private void Highlight(uint characterId)
{
if (_disposed)
return;
_bindings.Highlight(characterId);
InvalidateAndTick();
}
/// <summary>
/// Campaign CC slice CC7: <c>ListenToElementMessage</c> case 3 ->
/// <c>QueueUIMode(0x1000000b)</c>. Purely presentational — no Runtime
/// command, no roster/state change here; <see cref="_bindings"/>'
/// <c>RequestCreate</c> is resolved per-call (never captured) so it
/// reflects whatever <see cref="RetailUiRuntime"/> wired at the time of
/// the click, matching every other late-bound seam in this bindings
/// record.
/// </summary>
private void RequestCreate()
{
if (_disposed)
return;
_bindings.RequestCreate?.Invoke();
}
private void EnterSelected()
{
if (_disposed)
return;
// Open retail's wait context before the synchronous Runtime command
// starts its existing ServerReady transaction. The state projection
// remains authoritative and closes it on InWorld/error/reset.
EnsureEnterWait();
RuntimeCommandResult result = _bindings.Enter();
if (!result.Accepted)
CloseContext(ref _enterWaitContext, suppressCallback: true);
InvalidateAndTick();
}
private void RequestDelete()
{
if (_disposed)
return;
_bindings.RequestDelete();
InvalidateAndTick();
}
private void RestoreSelected()
{
if (_disposed)
return;
// ListenToElementMessage @ 0x004ED5A0 opens Please Wait before it
// calls CPlayerSystem::RestoreCharacter. Keep it modal even if a
// synchronous command callback re-enters Tick before Runtime has
// returned its accepted projection.
EnsureOperationWait();
RuntimeCommandResult result = default;
Exception? failure = null;
_restoreCommandInFlight = true;
try
{
result = _bindings.Restore();
}
catch (Exception error)
{
failure = error;
}
finally
{
_restoreCommandInFlight = false;
}
if (failure is not null)
{
Console.WriteLine(
$"[UI] character restore command failed: {failure.Message}");
CloseContext(ref _operationWaitContext, suppressCallback: true);
InvalidateAndTick();
return;
}
if (!result.Accepted)
CloseContext(ref _operationWaitContext, suppressCallback: true);
InvalidateAndTick();
}
private void RequestExit()
{
if (_disposed)
return;
// MakeConfirmExitDialog @ 0x004ed250's own guard: a second Exit
// click while the confirmation is already open is a no-op.
if (_confirmExitDialogContext != 0u)
return;
_confirmExitDialogContext = _dialogs.MakeConfirmation(
_strings.ConfirmExit,
data =>
{
_confirmExitDialogContext = 0u;
if (_disposed || _suppressDialogCallbacks)
return;
// RecvNotice_CloseDialog @ 0x004ed760 case 1: only a
// confirmed (OK) close proceeds through the SAME graceful
// shutdown path window-close uses; Cancel leaves the screen
// exactly as it was.
if (data.GetBoolean(RetailDialogProperty.ConfirmationResult))
_bindings.RequestExit();
});
}
private void ReconcileDialogs(
IRuntimeCharacterSelectionView view,
RuntimeCharacterSelectionSnapshot snapshot)
{
if (snapshot.Error is { } error)
{
CloseContext(ref _deleteDialogContext, suppressCallback: true);
CloseContext(ref _operationWaitContext, suppressCallback: true);
CloseContext(ref _enterWaitContext, suppressCallback: true);
EnsureError(error.Message);
return;
}
CloseContext(ref _errorDialogContext, suppressCallback: true);
if (snapshot.Lifecycle == RuntimeCharacterSelectionLifecycle.EnteringWorld)
{
CloseContext(ref _deleteDialogContext, suppressCallback: true);
CloseContext(ref _operationWaitContext, suppressCallback: true);
EnsureEnterWait();
return;
}
CloseContext(ref _enterWaitContext, suppressCallback: true);
if (snapshot.PendingDeleteCharacterId != 0u
&& view.TryGet(snapshot.PendingDeleteCharacterId, out RuntimeCharacterSelectionEntry pending))
{
EnsureDeleteConfirmation(pending.Name);
}
else
{
CloseContext(ref _deleteDialogContext, suppressCallback: true);
}
if (_restoreCommandInFlight
|| snapshot.Operation is RuntimeCharacterSelectionOperation.DeleteRequested
or RuntimeCharacterSelectionOperation.DeleteAcknowledged
or RuntimeCharacterSelectionOperation.RestoreRequested)
{
EnsureOperationWait();
}
else
{
CloseContext(ref _operationWaitContext, suppressCallback: true);
}
}
private void EnsureDeleteConfirmation(string characterName)
{
if (_deleteDialogContext != 0u)
return;
_deleteDialogContext = _dialogs.MakeConfirmationTextInput(
_strings.DeleteConfirmation(characterName),
data =>
{
_deleteDialogContext = 0u;
if (_disposed || _suppressDialogCallbacks)
return;
string response = data.GetString(
RetailDialogProperty.TextInputResult) ?? string.Empty;
if (string.Equals(
response,
_strings.DeleteResponse,
StringComparison.OrdinalIgnoreCase))
{
_bindings.ConfirmDelete();
}
else
{
_bindings.Cancel();
}
InvalidateAndTick();
});
}
private void EnsureOperationWait()
{
if (_operationWaitContext == 0u)
_operationWaitContext = _dialogs.MakeWait(_strings.PleaseWait);
}
private void EnsureEnterWait()
{
if (_enterWaitContext == 0u)
_enterWaitContext = _dialogs.MakeWait(_strings.EnteringWorld);
}
private void EnsureError(string message)
{
if (_errorDialogContext != 0u)
return;
_errorDialogContext = _dialogs.MakeMessage(
message,
_ =>
{
_errorDialogContext = 0u;
if (_disposed || _suppressDialogCallbacks)
return;
_bindings.Cancel();
InvalidateAndTick();
});
}
private void InvalidateAndTick()
{
_lastRevision = long.MinValue;
Tick();
}
private void Deactivate()
{
if (_active)
{
_active = false;
Root.Visible = false;
_host.RevokeFixedCanvas(this);
}
foreach (UiButton row in _rows)
{
row.OnClick = null;
row.OnDoubleClick = null;
}
_rows.Clear();
_rowIds.Clear();
_list.Flush();
CloseAllDialogs(suppressCallbacks: true);
}
private void CloseAllDialogs(bool suppressCallbacks)
{
bool previous = _suppressDialogCallbacks;
_suppressDialogCallbacks |= suppressCallbacks;
try
{
CloseContext(ref _deleteDialogContext, suppressCallback: false);
CloseContext(ref _operationWaitContext, suppressCallback: false);
CloseContext(ref _enterWaitContext, suppressCallback: false);
CloseContext(ref _errorDialogContext, suppressCallback: false);
CloseContext(ref _confirmExitDialogContext, suppressCallback: false);
}
finally
{
_suppressDialogCallbacks = previous;
}
}
private void CloseContext(ref uint context, bool suppressCallback)
{
uint closing = context;
if (closing == 0u)
return;
context = 0u;
bool previous = _suppressDialogCallbacks;
_suppressDialogCallbacks |= suppressCallback;
try
{
_dialogs.CloseDialog(closing);
}
finally
{
_suppressDialogCallbacks = previous;
}
}
}

View file

@ -0,0 +1,106 @@
namespace AcDream.App.UI.Layout;
internal sealed record CharacterManagementUiMountResources(
uint LayoutId,
ImportedLayout Layout,
Func<uint, uint, UiElement?> TemplateResolver,
CharacterManagementUiController.DialogStrings Strings);
/// <summary>
/// Retryable, idempotent composition edge for the pre-world character screen.
/// DATs can become readable after the graphical runtime starts (installer copy,
/// mapped-file replacement, or a transient catalog miss), so an unavailable
/// dialog catalog, root, template, or string must not permanently suppress the
/// screen. Once bound, later ticks are no-ops and cannot duplicate the root or
/// controller lifetime.
/// </summary>
internal sealed class CharacterManagementUiMountCoordinator : IDisposable
{
private readonly UiRoot _host;
private readonly CharacterSelectionRuntimeBindings _bindings;
private readonly Func<RetailDialogFactory?> _ensureDialogs;
private readonly Func<CharacterManagementUiMountResources?> _loadResources;
private bool _disposed;
public CharacterManagementUiMountCoordinator(
UiRoot host,
CharacterSelectionRuntimeBindings bindings,
Func<RetailDialogFactory?> ensureDialogs,
Func<CharacterManagementUiMountResources?> loadResources)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
_ensureDialogs = ensureDialogs
?? throw new ArgumentNullException(nameof(ensureDialogs));
_loadResources = loadResources
?? throw new ArgumentNullException(nameof(loadResources));
}
public CharacterManagementUiController? Controller { get; private set; }
public void Tick()
{
if (_disposed || Controller is not null)
return;
try
{
RetailDialogFactory? dialogs = _ensureDialogs();
if (dialogs is null)
return;
CharacterManagementUiMountResources? resources = _loadResources();
if (resources is null)
return;
CharacterManagementUiController? candidate =
CharacterManagementUiController.CreateDetached(
_host,
resources.Layout,
resources.TemplateResolver,
dialogs,
_bindings,
resources.Strings);
if (candidate is null)
return;
// Take ownership before the first attach/tick. Template resolution
// happens inside that tick and can throw after the root and button
// handlers are live; the catch below can therefore always retire
// the exact partial controller before a later retry.
Controller = candidate;
candidate.AttachAndTick();
Console.WriteLine(
$"[UI] retail character management from enum table 5 "
+ $"(0x10000005 -> 0x{resources.LayoutId:X8}, "
+ "root 0x1000039A; flat list, no viewport).");
}
catch (Exception error)
{
CharacterManagementUiController? partial = Controller;
Controller = null;
try
{
partial?.Dispose();
}
catch (Exception cleanupError)
{
Console.WriteLine(
"[UI] character management partial-mount cleanup failed: "
+ cleanupError.Message);
}
Console.WriteLine(
"[UI] character management mount will retry after resource "
+ $"recovery: {error.Message}");
}
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
Controller?.Dispose();
Controller = null;
}
}

View file

@ -0,0 +1,206 @@
using System.Collections.Generic;
using AcDream.App.Rendering;
using AcDream.Content;
using AcDream.Core.CharGen;
using AcDream.Core.Textures;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
namespace AcDream.App.UI.Layout;
/// <summary>
/// R3-5/R3-6 (Campaign CC gate round 1 re-test 2) seam: the pre-baked
/// textures <see cref="CharacterCreationAppearancePage"/>'s color-wheel
/// swatches and gradient disc draw instead of a plain multiply-<c>Tint</c>
/// over the authored sprite. See <see cref="UiButton.ColorKeyFaceResolver"/>'s
/// own doc for why a plain multiply is wrong here (it cannot recolor a
/// BLACK placeholder region at all, and it corrupts the ring border's own
/// colors).
/// </summary>
internal interface IChargenSwatchTextureSource
{
/// <summary>
/// Retail's "blank"/blocked swatch art (enum <c>0x1000000f</c>,
/// category 7 — <c>gmCGAppearancePage::DoColorSpots @0x0047d850</c>'s
/// <c>i &gt;= count</c> branch) — shown UNTINTED for a swatch beyond the
/// current part's real color count (retail's own
/// <c>pColor-&gt;SetVisible(1)</c> is unconditional for all 9 swatches;
/// only the CONTENT differs). 0 if unresolved.
/// </summary>
uint BlankSpotTexture { get; }
/// <summary>
/// Retail's gradient-disc art (enum <c>0x1000000e</c>, category 7) —
/// shown MULTIPLY-tinted by the currently selected swatch's own color,
/// matching retail's own <c>SurfaceWindow::BlitAndColor(...,
/// Blit_Multiply, color)</c> (<c>DoGradDisk @0x0047da90</c>'s non-Eyes
/// branch) — a genuine multiply, unlike the swatch spots. 0 if
/// unresolved.
/// </summary>
uint GradDiskTexture { get; }
/// <summary>
/// Retail's Eyes "grad plug" icon art (enum <c>0x10000010</c>, category
/// 7) — shown UNTINTED (<c>DoGradDisk</c>'s Eyes branch is a plain
/// <c>Blit_Normal</c>, no color argument at all). 0 if unresolved.
/// </summary>
uint GradPlugTexture { get; }
/// <summary>
/// Bakes (or returns a cached) recolored copy of the ACTIVE swatch spot
/// template (enum <c>0x1000000d</c>, category 7) with every EXACT-black
/// pixel replaced by <paramref name="rgb"/>'s own bytes (alpha and every
/// non-black pixel — the ring border — left untouched), matching retail's
/// <c>SurfaceWindow::ReplaceColor</c> call against old-color
/// <c>(0,0,0,1)</c>. 0 if the template is unresolved.
/// </summary>
uint GetActiveSpotTexture(ChargenSwatchRgb rgb);
}
/// <summary>
/// Live-DAT implementation of <see cref="IChargenSwatchTextureSource"/>.
/// Decodes each of the four <c>DoColorSpots</c>/<c>DoGradDisk</c> category-7
/// RenderSurfaces ONCE (live-DAT-measured: spot/blank are 37x44, gradDisk/
/// gradPlug are 110x112 — exactly matching the swatch buttons' and grad
/// circle's own authored rects), uploads the three static ones (blank/
/// gradDisk/gradPlug) once, and bakes+caches one recolored spot texture per
/// distinct <see cref="ChargenSwatchRgb"/> value on demand — mirroring the
/// SAME "decode once, composite/recolor per key, upload, cache" shape
/// <see cref="AcDream.App.UI.IconComposer"/> already established for item
/// icons and spell components (that class's own
/// <c>GetSpellComponentIcon</c> ports the identical exact-color-match
/// replace this class uses, just matching white instead of black).
/// </summary>
internal sealed class ChargenColorSpotComposer : IChargenSwatchTextureSource
{
private const uint SpotEnumId = 0x1000000Du;
private const uint BlankEnumId = 0x1000000Fu;
private const uint GradDiskEnumId = 0x1000000Eu;
private const uint GradPlugEnumId = 0x10000010u;
private const uint EnumCategory = 7u;
private readonly IDatReaderWriter _dats;
private readonly TextureCache _cache;
private DecodedTexture? _spotTemplate;
private bool _spotResolveTried;
private readonly Dictionary<(byte R, byte G, byte B), uint> _bakedSpotByColor = new();
private uint _blankTexture;
private bool _blankResolveTried;
private uint _gradDiskTexture;
private bool _gradDiskResolveTried;
private uint _gradPlugTexture;
private bool _gradPlugResolveTried;
public ChargenColorSpotComposer(IDatReaderWriter dats, TextureCache cache)
{
_dats = dats;
_cache = cache;
}
public uint BlankSpotTexture
{
get
{
if (!_blankResolveTried)
{
_blankResolveTried = true;
if (TryDecode(BlankEnumId, out DecodedTexture decoded))
_blankTexture = _cache.UploadRgba8(decoded.Rgba8, decoded.Width, decoded.Height, nearest: true);
}
return _blankTexture;
}
}
public uint GradDiskTexture
{
get
{
if (!_gradDiskResolveTried)
{
_gradDiskResolveTried = true;
if (TryDecode(GradDiskEnumId, out DecodedTexture decoded))
_gradDiskTexture = _cache.UploadRgba8(decoded.Rgba8, decoded.Width, decoded.Height, nearest: true);
}
return _gradDiskTexture;
}
}
public uint GradPlugTexture
{
get
{
if (!_gradPlugResolveTried)
{
_gradPlugResolveTried = true;
if (TryDecode(GradPlugEnumId, out DecodedTexture decoded))
_gradPlugTexture = _cache.UploadRgba8(decoded.Rgba8, decoded.Width, decoded.Height, nearest: true);
}
return _gradPlugTexture;
}
}
public uint GetActiveSpotTexture(ChargenSwatchRgb rgb)
{
if (!_spotResolveTried)
{
_spotResolveTried = true;
if (TryDecode(SpotEnumId, out DecodedTexture decoded))
_spotTemplate = decoded;
}
if (_spotTemplate is not { } template)
return 0u;
var key = (rgb.R, rgb.G, rgb.B);
if (_bakedSpotByColor.TryGetValue(key, out uint cached))
return cached;
byte[] baked = ReplaceExactBlackWithColor(template.Rgba8, rgb);
uint texture = _cache.UploadRgba8(baked, template.Width, template.Height, nearest: true);
_bakedSpotByColor[key] = texture;
return texture;
}
/// <summary>
/// Pure byte-level half of <see cref="GetActiveSpotTexture"/> — cloned,
/// GL-free, and unit-testable without a <c>TextureCache</c>. Retail's
/// own old-color argument to <c>SurfaceWindow::ReplaceColor</c> is
/// <c>RGBAColor(0,0,0,1)</c> — opaque black, ALL four channels, not
/// just RGB (the decompiled float quad's own alpha term is
/// <c>0x3f800000</c> = 1.0) — so a genuinely transparent padding pixel
/// (alpha 0, also RGB-zero in this port's own decoded padding) does
/// NOT match and is left untouched, exactly like the ring border.
/// Every matched pixel's RGB becomes <paramref name="rgb"/>'s own
/// bytes and alpha is forced to fully opaque (retail's own new-color
/// argument is ALSO alpha 1 — <c>SetColor</c>'s computed swatch color
/// carries a hardcoded opaque alpha, not the source pixel's). Mirrors
/// <see cref="AcDream.App.UI.IconComposer.GetSpellComponentIcon"/>'s
/// own exact-match convention (there, pure white) rather than an
/// invented fuzzy tolerance.
/// </summary>
internal static byte[] ReplaceExactBlackWithColor(byte[] rgba, ChargenSwatchRgb rgb)
{
byte[] baked = (byte[])rgba.Clone();
for (int i = 0; i + 3 < baked.Length; i += 4)
{
if (baked[i] != 0 || baked[i + 1] != 0 || baked[i + 2] != 0 || baked[i + 3] != 255)
continue;
baked[i] = rgb.R;
baked[i + 1] = rgb.G;
baked[i + 2] = rgb.B;
baked[i + 3] = 255;
}
return baked;
}
private bool TryDecode(uint enumId, out DecodedTexture decoded)
{
decoded = null!;
uint did = RetailDataIdResolver.Resolve(_dats, enumId, EnumCategory);
if (did == 0) return false;
if (!_dats.TryGet<RenderSurface>(did, out var rs) || rs is null) return false;
decoded = SurfaceDecoder.DecodeRenderSurface(rs);
return true;
}
}

View file

@ -207,7 +207,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
/// <param name="layout">Widget tree from <see cref="LayoutImporter.Build"/>.</param>
/// <param name="vm">Chat view-model (transcript data + command routing).</param>
/// <param name="busProvider">Factory that returns the live command bus at submit time.
/// Called on every chat submit so it resolves <see cref="AcDream.UI.Abstractions.LiveCommandBus"/>
/// Called on every chat submit so it resolves <see cref="LiveCommandBus"/>
/// even when the live session is established AFTER <see cref="Bind"/> runs
/// (mirrors the ImGui <c>ChatPanel</c> which re-reads the bus each frame).</param>
/// <param name="windowFilters">Runtime's canonical per-window filter/open state

View file

@ -0,0 +1,106 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Shared multi-segment rich-text composer for the chargen description
/// boxes (Campaign CC gate round 1 Batch C — GF-2/GF-3/GF-11a, and the
/// Summary how-to text). Ports retail's
/// <c>UIElement_Text::SetStringInfoWithFont</c> /
/// <c>AppendStringInfoWithFont @ 0x00469D70</c> composition model: a text
/// box is built from an ORDERED list of string segments, each carrying its
/// OWN font-color palette index
/// (<c>UIElement_Text::AppendStringInfoWithFont</c>'s
/// <c>SetFontColorHelper</c> -&gt; <c>InqProperty(0x1B)</c> array lookup —
/// see <see cref="AcDream.App.UI.UiText.FontColorPalette"/>).
///
/// <para>
/// The description pages used to bypass this entirely: they assigned a raw
/// <c>LinesProvider</c> lambda returning ONE unwrapped <see cref="AcDream.App.UI.UiText.Line"/>
/// per composed string, with no escape-normalize and no word-wrap. Two
/// concrete symptoms this caused: literal two-character <c>"\n"</c>
/// escapes rendered as backslash-n instead of a real line break (the DAT
/// stores that literal escape — <c>DatWidgetFactory.BuildText</c>'s own
/// authored-string path already normalizes it for single-element authored
/// captions; this helper reproduces the SAME normalize for
/// runtime-composed multi-segment text), and — for the Town page
/// specifically — an unwrapped single line meant the town-specific SUFFIX
/// of the composed string rendered far outside the box's clipped viewport,
/// so switching towns looked like "the text never changes" even though the
/// underlying string genuinely did (only its INVISIBLE tail differed).
/// </para>
/// </summary>
internal static class DatRichText
{
/// <summary>One composed segment: text plus the color it should render
/// in. A null or empty <see cref="Text"/> is silently skipped (mirrors
/// retail's own null-string-info no-op guards throughout this text
/// composition family).</summary>
public readonly record struct Segment(string? Text, Vector4 Color);
/// <summary>
/// Escape-normalizes and word-wraps every segment (independently, so
/// each segment's wrapped lines keep ITS OWN color), then concatenates
/// the results in order. No separator is inserted between segments —
/// retail's own composition calls concatenate directly
/// (<c>AppendStringInfoWithFont</c>/<c>append_n_chars</c> with no
/// interposed literal), so any blank-line spacing between sections
/// comes from the authored DAT string content itself, not from code
/// here.
/// </summary>
public static IReadOnlyList<UiText.Line> Compose(
UiText target,
IReadOnlyList<Segment> segments)
{
ArgumentNullException.ThrowIfNull(target);
ArgumentNullException.ThrowIfNull(segments);
var lines = new List<UiText.Line>();
// R2-1 (Campaign CC gate round 1 Batch E): the wrap width must shrink
// by the SAME left+right inset the draw path now applies (Padding
// plus the four retail margins, UiText.MarginLeft's own doc) — the
// Batch-C regression's second half: text wasn't just drawing at the
// wrong X, it was also wrapping to the FULL box width instead of the
// authored interior width, overflowing the visible right edge too.
float maximumWidth = MathF.Max(
1f,
target.Width - (target.Padding + target.MarginLeft) - (target.Padding + target.MarginRight));
Func<string, float> measure = target.DatFont is { } font
? font.MeasureWidth
: static value => value.Length * 8f;
foreach (Segment segment in segments)
{
if (string.IsNullOrEmpty(segment.Text))
continue;
// The installed DAT stores the LITERAL two-character escape
// "\n" (0x5C 0x6E), not a real line break — same normalize
// DatWidgetFactory.BuildText's authored-string path already
// applies for single-element authored captions.
string normalized = segment.Text
.Replace("\\n", "\n")
.Replace("\r", string.Empty);
foreach (string wrapped in UiText.WrapWords(normalized, measure, maximumWidth))
lines.Add(new UiText.Line(wrapped, segment.Color));
}
return lines;
}
/// <summary>
/// Resolves <paramref name="target"/>'s own authored font-color
/// palette (dat property <c>0x1B</c>) entry at <paramref name="index"/>,
/// falling back to <paramref name="fallback"/> when the palette is
/// absent or too short. Mirrors the same fallback shape
/// <c>CharacterStatController.BuildSelectedTitleRuns</c> already uses
/// for its own palette-indexed colors.
/// </summary>
public static Vector4 PaletteColor(UiText target, int index, Vector4 fallback) =>
index >= 0 && index < target.FontColorPalette.Count
? target.FontColorPalette[index]
: fallback;
}

View file

@ -122,6 +122,8 @@ public static class DatWidgetFactory
11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137)
12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text
0x13 => new UiDialogRoot(), // ConfirmationDialog
0x15 => new UiDialogRoot(), // ConfirmationTextInputDialog
0x17 => new UiDialogRoot(), // MessageDialog
0x19 => new UiDialogRoot(), // WaitDialog (catalog root 0x31 — OP8 #396)
0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots
0x10000035u => BuildCheckbox(
@ -313,6 +315,31 @@ public static class DatWidgetFactory
if (slices.Length > 0) bar.ThumbTopSprite = DefaultImage(slices[0]);
if (slices.Length > 1) bar.ThumbSprite = DefaultImage(slices[1]);
if (slices.Length > 2) bar.ThumbBotSprite = DefaultImage(slices[^1]);
// R3-4/R3-7 (Campaign CC gate round 1 re-test 2): retail authors
// TWO distinct thumb shapes for UIElement_Scrollbar (Type 11) —
// chat's own scrollbar (0x10000012) is the 3-slice composite the
// block above was built against (the thumb CHILD carries no media
// of its own; three Type-3 grandchildren supply the top-cap/
// middle/bottom-cap sprites) — but the chargen Skills listbox
// (0x100003f8), Summary's OVERVIEW listbox (0x10000401), and the
// Summary how-to box (0x100002e7 under 0x10000404) all author a
// SIMPLE single-sprite thumb instead: the SAME structural child
// (Type 1, id 1, not the inc/dec button) carries its OWN direct
// Normal/Normal_rollover/Normal_pressed media and has ZERO
// children (live-DAT-probe-confirmed against all three — no
// slice grandchildren to find, so `slices` above is always
// empty for this shape and every Thumb*Sprite stayed 0,
// matching the reported "track+arrows render, no thumb"
// symptom). <see cref="UiScrollbar.OnDraw"/> already falls back
// to a single tiled `ThumbSprite` blit when the cap sprites are
// unset (`ThumbTopSprite != 0 && ThumbBotSprite != 0` gate), so
// the only missing piece is feeding it the thumb's OWN media
// when it has no slice children — additive: a thumb WITH real
// slice children (chat) is unaffected since `slices.Length == 0`
// is false for that shape.
if (slices.Length == 0)
bar.ThumbSprite = DefaultImage(thumb);
}
return bar;
@ -728,6 +755,14 @@ public static class DatWidgetFactory
// ElementInfo.Outline's own default, so this is a no-op for the ~99% of text
// elements that don't author it.
Outline = info.Outline,
// R2-1 (Campaign CC gate round 1 Batch E): the four text-inset
// margins (dat properties 0x23-0x26 — MarginLeft's own doc
// comment on UiText). Default 0 — a no-op for every element that
// doesn't author them (only consumed by the multi-line path).
MarginLeft = info.MarginLeft,
MarginRight = info.MarginRight,
MarginTop = info.MarginTop,
MarginBottom = info.MarginBottom,
};
t.ConfigureDatState(info);
@ -779,7 +814,12 @@ public static class DatWidgetFactory
cachedWidth = t.Width;
cachedFont = t.DatFont;
cachedColor = t.DefaultColor;
float maximumWidth = Math.Max(1f, t.Width - 2f * t.Padding);
// R2-1: shrink by BOTH Padding and the four retail
// margins — see DatRichText.Compose's own comment on
// the same formula.
float maximumWidth = Math.Max(
1f,
t.Width - (t.Padding + t.MarginLeft) - (t.Padding + t.MarginRight));
Func<string, float> measure = t.DatFont is { } font
? font.MeasureWidth
: static value => value.Length * 8f;
@ -808,7 +848,8 @@ public static class DatWidgetFactory
|| !state.Properties.Values.TryGetValue(0x17u, out var stateCaption)
|| stateCaption.Kind != UiPropertyKind.StringInfo)
continue;
if (stringResolve?.Invoke(stateCaption.StringInfoValue) is { Length: > 0 } text)
if (NormalizeEscapes(stringResolve?.Invoke(stateCaption.StringInfoValue))
is { Length: > 0 } text)
(stateStrings ??= new Dictionary<uint, string>())[stateId] = text;
}
if (stateStrings is not null)
@ -876,18 +917,185 @@ public static class DatWidgetFactory
button.FaceTop = face.Y;
button.FaceWidth = face.Width;
button.FaceHeight = face.Height;
button.LabelAlign = UiButton.LabelAlignment.Left;
button.LabelOffsetX = face.X + face.Width + 4f;
if (!ReferenceEquals(labelInfo, info))
{
// GF-11c (Campaign CC gate round 1 Batch B): a DISTINCT
// Type-12 caption was lifted (e.g. the Town page's per-
// marker name label, 0x10000409 under each town button —
// live-DAT-probe-confirmed authored rect + Center justify,
// independent of the marker face's own geometry) — honor
// ITS OWN authored rect/justify instead of the face-
// relative offset below, which is only correct when the
// label text is authored DIRECTLY on the button itself,
// immediately beside a single-purpose face segment (the
// heritage/template/Face-Clothes sub-tab row family —
// still handled by the else-branch two lines down, since
// ReferenceEquals(labelInfo, info) is true there).
button.LabelBox = (labelInfo.X, labelInfo.Y, labelInfo.Width, labelInfo.Height);
button.LabelAlign = labelInfo.HJustify == HJustify.Left
? UiButton.LabelAlignment.Left
: UiButton.LabelAlignment.Center;
}
else if (!ReferenceEquals(labelInfo, info) && labelInfo.HJustify == HJustify.Left)
else
{
button.LabelAlign = UiButton.LabelAlignment.Left;
// F10 (Campaign CC gate round 1 closeout): this +4f gap and
// UiButton.LabelOffsetX's own class-default 3f (used by the
// "no face, not lifted" branch below, AND by any caller —
// e.g. PaperdollController's "Slots" label — that sets
// LabelAlign=Left directly with no DatWidgetFactory
// involvement at all) are DELIBERATELY not the same number,
// not an unreconciled oversight: neither carries a retail
// decomp citation (both are acdream-synthesized small
// insets), and they answer different questions — this one
// is "gap after a REAL adjacent face element" (a geometry-
// derived offset), the other is "default left inset when
// there is no reference geometry at all" (a context-free
// fallback). Moving either number to match the other would
// be an unfounded 1px guess on whichever button currently
// works, not a fix — see DatWidgetFactoryTests' own
// `face.X(0) + face.Width(32) + 4` pin for this exact site.
button.LabelOffsetX = face.X + face.Width + 4f;
}
}
else if (labelInfo.HJustify == HJustify.Left)
{
// Campaign LA gate round 2 finding 2: the guard used to require
// labelInfo to be a LIFTED Type-12 text child (!ReferenceEquals),
// so a button authoring its OWN HJustify=Left with no separate
// label child — e.g. gmCharacterManagementUI's character-list row
// template (0x21000004/0x100003A5: HJustify=Left, three stateful
// Type-3 highlight-art children, no Type-12 caption child) — fell
// through with LabelAlign left at UiButton's Center default.
// Live-DAT probe confirmed: rowInfo.HJustify=Left,
// authoredFaces.Length=3 (faceSegments, not a single face), no
// Type-12 child, and the built row's LabelAlign came out Center.
// labelInfo.X is only a valid inner-offset when a distinct child
// was actually lifted; for the direct (labelInfo == info) case,
// leave UiButton's own default 3px LabelOffsetX in place — see
// the face-relative +4f branch above (F10) for why this 3px
// default and that 4px gap are deliberately different numbers,
// not an unreconciled asymmetry.
button.LabelAlign = UiButton.LabelAlignment.Left;
if (!ReferenceEquals(labelInfo, info))
button.LabelOffsetX = labelInfo.X;
}
// AP-222 / GF-11b (Campaign CC gate round 1 Batch B): per-state label
// color/outline (dat properties 0x1B/0x21 authored PER STATE on the
// label-bearing element — the Appearance spins' own states, or the
// Town caption child's states) — additive, only non-null when the
// authored dat genuinely carries more than one distinct value.
button.SetPerStateLabelStyle(
ElementReader.BuildPerStateColorMap(labelInfo, 0x1Bu),
ElementReader.BuildPerStateBoolMap(labelInfo, 0x21u));
// GF-4a (Campaign CC gate round 1 Batch C): retail's chargen
// display buttons author the caption directly as THEIR OWN P0x17
// (so `label` above resolved from `info` itself, not a lifted
// child) AND carry a SEPARATE, media-less Type-12 child for the
// live value (gmCGProfessionPage::InitializePage
// @0x00482f90-0x00483062, gmCGSkillsPage::InitializePage
// @0x00481e1c — live-DAT-measured: exactly one Type-12 child, zero
// StateMedia entries). Gated tightly to that exact shape so this
// stays a no-op for every other button (a lifted-caption button
// never reaches here with labelInfo==info; a button with an icon/
// face child instead of a value child has no media-less Type-12
// child to find).
if (ReferenceEquals(labelInfo, info) && label is not null)
{
ElementInfo? valueChild = info.Children.FirstOrDefault(
child => child.Type == 12u && child.StateMedia.Count == 0);
if (valueChild is not null)
{
// R4-1 (Campaign CC gate round 1 re-test 3): reflow the value
// child's authored rect through retail's own raw-edge policy
// (UIElement::UpdateForParentSizeChange @0x00462640, ported
// as UiLayoutPolicy) before it becomes ValueBox — see
// ReflowValueChildRect's own doc for why this is needed and
// decomp-cited.
button.ValueBox = ReflowValueChildRect(valueChild, info);
button.ValueFont = valueChild.FontDid != 0u && fontResolve is not null
? fontResolve(valueChild.FontDid) ?? elementFont
: elementFont;
button.ValueColor = valueChild.FontColor ?? System.Numerics.Vector4.One;
button.ValueAlign = valueChild.HJustify switch
{
HJustify.Left => UiButton.LabelAlignment.Left,
// R4-1: HJustify.Right (raw dat 3/5) previously fell into
// this ternary's Center branch — CalcJustification's own
// ecx_5==3||5 case is a DISTINCT far-edge formula (see
// UiButton.LabelAlignment.Right's own doc), and every
// value child in this family (0x100002f1/0x100002f3)
// authors HJustify Right, live-DAT-confirmed.
HJustify.Right => UiButton.LabelAlignment.Right,
_ => UiButton.LabelAlignment.Center,
};
// Seed with whatever the child itself authors (typically
// blank) so an unbound button doesn't draw stray leftover
// text before a controller writes a real value.
button.ValueLabel = ResolveAuthoredString(valueChild, stringResolve);
}
}
return button;
}
/// <summary>
/// R4-1 (Campaign CC gate round 1 re-test 3): the "Available Skill
/// Credits" value overlapped mid-caption ("Available Skill0Credits")
/// because <see cref="UiButton.ValueBox"/> was built from the value
/// child's RAW authored rect, un-reflowed. Live-DAT probe: the value
/// child (<c>0x100002f3</c>) 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 <c>OriginalParentWidth</c> (the
/// design-time parent size baked in at whichever button FIRST resolved
/// it — 150, matching Health's own actual width) diverges from Skills
/// credits' actual current parent width (231) — exactly the shape
/// <see cref="UiLayoutPolicy"/> (retail
/// <c>UIElement::UpdateForParentSizeChange @0x00462640</c>, already the
/// production raw-edge reflow for live mounted elements via
/// <see cref="UiElement.ApplyAnchor"/>) exists to correct. The child's
/// own edge modes (Left=2/Right=1, live-DAT-confirmed) are retail's
/// "track the far edge as the parent grows" reflow: applying them moves
/// the value box from local X=116 to X=197 for Skills credits — landing
/// immediately after the caption's own measured end (~x=196,
/// <c>SkillsCreditsButton_CaptionFitsFullWidth_ValueChildStartsAtMidpoint</c>)
/// instead of colliding mid-caption. Health/Stamina/Mana and the
/// Attribute/Credits value child (whose OWN OriginalParentWidth already
/// matches their actual parent, or whose edge modes are all 0/fixed)
/// reflow to their byte-identical raw rect (deltaX=0 or mode-0 passthrough)
/// — this is additive for every already-correct button, not a per-button
/// special case.
/// </summary>
private static (float X, float Y, float Width, float Height) ReflowValueChildRect(
ElementInfo child, ElementInfo parent)
{
float originalParentWidth = child.HasOriginalParentSize ? child.OriginalParentWidth : parent.Width;
float originalParentHeight = child.HasOriginalParentSize ? child.OriginalParentHeight : parent.Height;
var originalChild = UiPixelRect.FromPositionAndSize(
(int)child.X, (int)child.Y, (int)child.Width, (int)child.Height);
var originalParent = UiPixelRect.FromPositionAndSize(
0, 0, (int)originalParentWidth, (int)originalParentHeight);
var currentParent = UiPixelRect.FromPositionAndSize(
0, 0, (int)parent.Width, (int)parent.Height);
// Empty (Width=0/Height=0) "current child" so the static Apply's
// currentChild-preservation branch never engages — every axis comes
// from the Near/Far formula, matching mode 0's own "keep the raw
// authored edge" default for the (frequent) no-anchor case.
var noCurrentChild = new UiPixelRect(0, 0, -1, -1);
UiPixelRect reflowed = UiLayoutPolicy.Apply(
child.Left, child.Top, child.Right, child.Bottom,
originalChild, originalParent,
noCurrentChild, currentParent);
return (reflowed.X0, reflowed.Y0, reflowed.Width, reflowed.Height);
}
/// <summary>
/// Retail UIOption_Checkbox is a UIElement_Button whose visible face is its
/// authored indicator child. Its label lives on the option object rather than
@ -947,6 +1155,32 @@ public static class DatWidgetFactory
|| !info.TryGetEffectiveProperty(0x17u, out var property)
|| property.Kind != UiPropertyKind.StringInfo)
return null;
return stringResolve(property.StringInfoValue);
string? resolved = stringResolve(property.StringInfoValue);
// R2-2 (Campaign CC gate round 1 Batch E): the DAT stores the LITERAL
// two-character escape "\n" (0x5C 0x6E), not a real line break — same
// fact BuildText's own authored-string path already normalized for
// (see that call site's own comment). Centralizing the normalize
// HERE, at the single choke point every P0x17 caption resolution in
// this file goes through (BuildText, BuildButton's own caption AND
// its lifted-child caption, BuildButton's coexisting ValueLabel,
// BuildCheckbox), closes the exact class of bug R2-2 found: a caption
// like the Profession credits button's own "Attribute\n Credits"
// rendered the literal backslash-n because BuildButton never
// normalized while BuildText did. BuildText's own subsequent
// Replace("\\n","\n") is now a harmless no-op (idempotent) — left in
// place rather than removed, since it costs nothing and documents the
// same fact locally.
return NormalizeEscapes(resolved);
}
/// <summary>
/// R2-2 (Campaign CC gate round 1 Batch E): the shared escape-normalize
/// <see cref="ResolveAuthoredString"/> applies, pulled out so the
/// per-STATE authored-caption loop below (which resolves a state's own
/// <c>0x17</c> directly, bypassing the effective-property resolution
/// <see cref="ResolveAuthoredString"/> wraps) gets the SAME normalize
/// instead of a second, easily-forgotten copy.
/// </summary>
private static string? NormalizeEscapes(string? raw) =>
raw?.Replace("\\n", "\n").Replace("\r", string.Empty);
}

View file

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.App.UI;
@ -225,6 +226,46 @@ public sealed class ElementInfo
/// </summary>
public uint ScrollbarElementId;
/// <summary>
/// GF-13 (Campaign CC gate round 1, Batch A): the authored Invisible flag
/// from dat property <c>0x3B</c> (<c>BoolBaseProperty</c>). Retail
/// <c>UIElement::OnSetAttribute @0x00462d80</c>'s case 8
/// (<c>BaseProperty::GetPropertyName(esi) - 0x33 == 8</c>, i.e. property
/// id <c>0x33 + 8 = 0x3B</c>): <c>this-&gt;vtable-&gt;SetVisible(value == 0)</c> —
/// an authored <c>true</c> HIDES the element at construction. Populated the
/// same way as <see cref="TabTable"/>/<see cref="ScrollbarElementId"/>
/// (recomputed fresh from the effective merged state every call), but this
/// is a PURE DATA ADDITION: the shared <see cref="LayoutImporter"/>/
/// <see cref="DatWidgetFactory"/> path does not act on it. 1,083 elements
/// author this flag client-wide (docs/ISSUES.md #408, its own separately-
/// gated general-honor item) — only screens that explicitly walk their own
/// mounted subtree and check this field may hide elements by it (see
/// <c>CharacterCreationUiController</c>'s chargen-scoped honor, register
/// AP-230).
/// </summary>
public bool Invisible;
/// <summary>
/// Campaign CC gate round 1 Batch E (R2-1): the four independent
/// <c>UIElement_Text</c> text-inset margins, dat properties
/// <c>0x23</c>/<c>0x24</c>/<c>0x25</c>/<c>0x26</c> (IntegerBaseProperty
/// — <c>UIElement_Text::OnSetAttribute @0x0046a640</c> cases
/// <c>0xf</c>/<c>0x10</c>/<c>0x11</c>/<c>0x12</c>, i.e.
/// <c>BaseProperty::GetPropertyName(arg2) - 0x14</c>, writing
/// <c>m_margL</c>/<c>m_margR</c>/<c>m_margU</c>/<c>m_margD</c>). Ctor
/// default is 0 on all four (<c>UIElement_Text::UIElement_Text
/// @0x004686d1-0046872d</c> clears them before any authored value
/// applies). The chargen description boxes author <c>margL=9,
/// margR=26, margU=15, margD=15</c> (live-DAT-probe-confirmed on
/// <c>0x100003C4</c>/<c>0x100003E0</c>/<c>0x10000409</c>/
/// <c>0x10000404</c>) — this codebase never read these four
/// properties before this fix, so every DAT-imported <c>UiText</c>
/// drew flush against its own outer rect (<c>Padding</c> alone,
/// always 0 for DAT-built text) regardless of what the DAT actually
/// authored.
/// </summary>
public int MarginLeft, MarginRight, MarginTop, MarginBottom;
/// <summary>
/// Resolves a property for a state using retail's DirectState-as-base rule. A
/// named state's key overrides DirectState by presence, including false/zero.
@ -401,6 +442,15 @@ public static class ElementReader
Outline = derived.Outline || base_.Outline,
// OutlineColor: same "non-null derived wins" rule as FontColor.
OutlineColor = derived.OutlineColor ?? base_.OutlineColor,
// R2-1: margins follow the same "non-zero derived wins" convention as
// FontDid/ZLevel above — a derived element that authors no margin
// property (0 is ApplyCanonicalLegacyProjection's own unset default,
// matching retail's ctor-cleared default too) inherits the base
// prototype's margin instead of silently zeroing it out.
MarginLeft = derived.MarginLeft != 0 ? derived.MarginLeft : base_.MarginLeft,
MarginRight = derived.MarginRight != 0 ? derived.MarginRight : base_.MarginRight,
MarginTop = derived.MarginTop != 0 ? derived.MarginTop : base_.MarginTop,
MarginBottom = derived.MarginBottom != 0 ? derived.MarginBottom : base_.MarginBottom,
// DefaultStateName: derived wins if set; otherwise inherit the base's default.
DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName,
// This helper merges one element snapshot only. LayoutImporter separately
@ -506,6 +556,20 @@ public static class ElementReader
}
}
// R2-1 (Campaign CC gate round 1 Batch E): the four text-inset margins
// (0x23 Left / 0x24 Right / 0x25 Up / 0x26 Down, IntegerBaseProperty —
// see MarginLeft's own doc comment for the decomp anchor). Absent
// properties leave the ElementInfo default of 0, matching retail's
// ctor-cleared default.
if (info.TryGetEffectiveInteger(0x23u, out int marginLeft))
info.MarginLeft = marginLeft;
if (info.TryGetEffectiveInteger(0x24u, out int marginRight))
info.MarginRight = marginRight;
if (info.TryGetEffectiveInteger(0x25u, out int marginTop))
info.MarginTop = marginTop;
if (info.TryGetEffectiveInteger(0x26u, out int marginBottom))
info.MarginBottom = marginBottom;
// Tab table (0x2E): array of StructBaseProperty (MasterPropertyId 0x2F) — the
// Type-8 tab control's authored {button element, page element, isDefault} rows
// (docs/research/2026-08-10-options-panel-structure.md §1.3). Recomputed fresh
@ -529,6 +593,16 @@ public static class ElementReader
// (DataId), UnsignedValue 100683031/100683033 == 0x06004D17/0x06004D19).
info.LedCheckedSprite = ReadReferencedElementId(info, 0x10000082u);
info.LedUncheckedSprite = ReadReferencedElementId(info, 0x10000083u);
// GF-13: Invisible (0x3B), BoolBaseProperty. Retail
// UIElement::OnSetAttribute @0x00462d80 case 8 — SetVisible(value == 0),
// so an authored true HIDES the element. Read via the same
// TryGetEffectiveBool the DirectState/default-state resolution rules
// already use for every other canonical-projection property above.
if (info.TryGetEffectiveBool(0x3Bu, out bool invisible))
{
info.Invisible = invisible;
}
}
private static List<UiTabTableEntry> ReadTabTable(ElementInfo info)
@ -644,4 +718,65 @@ public static class ElementReader
})
.ToArray();
}
/// <summary>
/// AP-222 / GF-11b (Campaign CC gate round 1 Batch B): resolves a color
/// property (0x1B FontColor's Array-tolerant shape, same unwrap as
/// <see cref="ReadEffectiveColorPalette"/>) for EVERY state <paramref
/// name="info"/> itself authors, keyed by retail numeric state id.
/// Returns null unless at least two states resolve to GENUINELY
/// DIFFERENT colors — the overwhelming majority of elements author one
/// color for every state (or none at all), and for those this returns
/// null so the caller keeps its existing single-default-color behavior
/// untouched. Only elements that really do recolor per state (the
/// Appearance spins' Highlight brightening, the Town buttons' Normal-
/// to-white caption swap) get a non-null map.
/// </summary>
internal static IReadOnlyDictionary<uint, Vector4>? BuildPerStateColorMap(
ElementInfo info, uint propertyId)
{
Dictionary<uint, Vector4>? map = null;
foreach (uint stateId in info.States.Keys)
{
if (!info.TryGetEffectiveProperty(propertyId, out UiPropertyValue value, stateId))
continue;
UiPropertyValue? colorValue = value.Kind == UiPropertyKind.Color
? value
: value.Kind == UiPropertyKind.Array
&& value.ArrayValue.Count > 0
&& value.ArrayValue[0].Kind == UiPropertyKind.Color
? value.ArrayValue[0]
: null;
if (colorValue is null)
continue;
UiColorValue c = colorValue.ColorValue;
float alpha = c.Alpha == 0 ? 1f : c.Alpha / 255f;
(map ??= new Dictionary<uint, Vector4>())[stateId] =
new Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, alpha);
}
return map is { Count: > 1 } && map.Values.Distinct().Count() > 1 ? map : null;
}
/// <summary>
/// AP-222 counterpart of <see cref="BuildPerStateColorMap"/> for a bool
/// property (0x21 Outline) — same "null unless genuinely per-state"
/// gating.
/// </summary>
internal static IReadOnlyDictionary<uint, bool>? BuildPerStateBoolMap(
ElementInfo info, uint propertyId)
{
Dictionary<uint, bool>? map = null;
foreach (uint stateId in info.States.Keys)
{
if (!info.TryGetEffectiveProperty(propertyId, out UiPropertyValue value, stateId)
|| value.Kind != UiPropertyKind.Bool)
continue;
(map ??= new Dictionary<uint, bool>())[stateId] = value.BoolValue;
}
return map is { Count: > 1 } && map.Values.Distinct().Count() > 1 ? map : null;
}
}

View file

@ -1715,8 +1715,11 @@ public static class ItemAppraisalTextFormatter
_ => string.Empty,
};
/// <summary><c>AppraisalSystem::SkillToString @ 0x005B4A30</c>.</summary>
private static string SkillName(int skill) => skill switch
/// <summary><c>AppraisalSystem::SkillToString @ 0x005B4A30</c> — retail
/// skill-id -&gt; display-name table. Made <c>internal</c> (Campaign CC
/// slice CC4) so the chargen Skills page can reuse the same names
/// instead of duplicating this table.</summary>
internal static string SkillName(int skill) => skill switch
{
1 => "Axe",
2 => "Bow",

View file

@ -117,6 +117,10 @@ public static class LayoutImporter
var w = DatWidgetFactory.Create(info, resolve, datFont, fontResolve, stringResolve);
if (w is null) return null; // Type-12 style prototype — skip
// GF-13: pure data passthrough — see UiElement.AuthoredInvisible's own
// doc comment for why this does NOT set Visible here.
w.AuthoredInvisible = info.Invisible;
if (info.Id != 0) byId[info.Id] = w;
// Behavioral widgets that draw their full appearance + reproduce their dat
@ -160,6 +164,48 @@ public static class LayoutImporter
if (cw is not null) w.AddChild(cw);
}
}
else if (w is UiText or UiField)
{
// Campaign CC gate round 1 Batch C, Commit 2: UiText/UiField's
// coarse ConsumesDatChildren=true (UiText outside its
// PassToChildren carve-out; UiField unconditionally) used to
// drop EVERY dat child, including ones that carry their own
// renderable media — retail's UIElement_Text/Field genuinely
// composites those as real chrome/controls, not swallowed
// caption/face art the way a Button's or Meter's children are.
// Live-DAT-measured (chargen's three shared description boxes,
// 0x100003e0/0x10000409/0x10000404): the eight gold-frame
// pieces (0x100002DE-E3, 0x100000E8/EA, Type 3, one DirectState
// sprite each) and the linked scrollbar (0x100002E7, Type 11,
// its own DirectState track sprite plus three Button
// sub-children BuildScrollbar resolves internally) all carry
// non-empty StateMedia on THEMSELVES. Purely structural/
// property-only children (StateMedia.Count == 0 — e.g. a
// lifted-caption-only child some OTHER element type might
// still want swallowed) stay dropped exactly as before; this
// is additive, not a relaxation of the PassToChildren gate
// itself.
foreach (var child in info.Children)
{
if (child.StateMedia.Count == 0) continue;
var cw = BuildWidget(child, resolve, datFont, fontResolve, stringResolve, byId);
if (cw is null) continue;
// F5/F6 (Campaign CC gate round 1 closeout): a NARROW honor
// of AuthoredInvisible, scoped to children reached through
// THIS carve-out only — e.g. the chat new-text indicator
// (0x1000048C, live-DAT-confirmed Invisible=true on every
// layout it appears in) would otherwise render as a phantom
// element retail never shows, now that this carve-out
// builds it as a real widget instead of silently dropping
// it. This is NOT the general client-wide honor (#408,
// 1,083 elements) — every OTHER AuthoredInvisible consumer
// stays data-only, acted on nowhere but chargen's own
// HideAuthoredInvisibleElements walk (register AP-230).
if (cw.AuthoredInvisible)
cw.Visible = false;
w.AddChild(cw);
}
}
// UIElement::SetState @ 0x00464E70 propagates a state's id only after the
// child tree exists. Re-applying the imported default here gives retained

View file

@ -129,10 +129,15 @@ internal sealed class RetailConfirmationDialogView : IRetailDialogView
private void SizeAndCenter()
{
// Center against the space the tree lays out in — the fixed authored
// canvas while a pre-world screen is active (gate round 2: centering
// against the raw window width put the exit dialog far right of the
// stretched 800x600 canvas center).
var space = _host.EffectiveCanvasSize;
Root.Left = 0f;
Root.Top = 0f;
Root.Width = _host.Width;
Root.Height = _host.Height;
Root.Width = space.X;
Root.Height = space.Y;
_popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f);
_popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f);
}

View file

@ -0,0 +1,163 @@
namespace AcDream.App.UI.Layout;
/// <summary>
/// Retail type-5 <c>ConfirmationTextInputDialog</c> (class type
/// <c>0x15</c>, catalog root <c>0x2C</c>). Accept stores the field text under
/// property <c>0x9C</c>; reject/Escape stores the empty string. Character
/// deletion is the first consumer and performs retail's case-insensitive
/// comparison with the localized <c>DELETE</c> response in its callback.
/// </summary>
internal sealed class RetailConfirmationTextInputDialogView : IRetailDialogView
{
public const uint RootElementId = 0x2Cu;
public const uint InputElementId = 0x2Cu;
public const uint AcceptButtonId = 0x2Eu;
public const uint RejectButtonId = 0x2Fu;
public const uint PopupElementId = 0x3Du;
public const uint MessageElementId = 0x3Eu;
private readonly UiRoot _host;
private readonly RetailDialogData _data;
private readonly uint _context;
private readonly Action<uint> _closeDialog;
private readonly UiElement _popup;
private readonly UiText _message;
private readonly UiField _input;
private readonly UiButton _accept;
private readonly UiButton _reject;
private readonly float _basePopupHeight;
private readonly float _baseMessageHeight;
private bool _focusPending = true;
public RetailConfirmationTextInputDialogView(
UiRoot host,
ImportedLayout layout,
RetailDialogData data,
uint context,
Action<uint> closeDialog)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
ArgumentNullException.ThrowIfNull(layout);
_data = data ?? throw new ArgumentNullException(nameof(data));
_context = context;
_closeDialog = closeDialog ?? throw new ArgumentNullException(nameof(closeDialog));
Root = layout.Root as UiDialogRoot
?? throw new ArgumentException(
"Confirmation-text-input layout root is not a UiDialogRoot.",
nameof(layout));
_popup = layout.FindElement(PopupElementId)
?? throw new ArgumentException(
"Confirmation-text-input layout is missing popup element 0x3D.",
nameof(layout));
_message = layout.FindElement(MessageElementId) as UiText
?? throw new ArgumentException(
"Confirmation-text-input layout is missing text element 0x3E.",
nameof(layout));
// The field deliberately repeats the root's numeric id. ImportedLayout
// registers descendants after ancestors, matching GetChildRecursive's
// effective result for this catalog shape.
_input = layout.FindElement(InputElementId) as UiField
?? throw new ArgumentException(
"Confirmation-text-input layout is missing input field 0x2C.",
nameof(layout));
_accept = layout.FindElement(AcceptButtonId) as UiButton
?? throw new ArgumentException(
"Confirmation-text-input layout is missing accept button 0x2E.",
nameof(layout));
_reject = layout.FindElement(RejectButtonId) as UiButton
?? throw new ArgumentException(
"Confirmation-text-input layout is missing reject button 0x2F.",
nameof(layout));
_basePopupHeight = _popup.Height;
_baseMessageHeight = _message.Height;
_popup.LayoutPolicy = null;
_popup.Anchors = AnchorEdges.None;
_message.LayoutPolicy = null;
_message.Anchors = AnchorEdges.None;
_message.Padding = 0f;
_message.Selectable = false;
_input.ClearOnSubmit = false;
_input.RecordHistory = false;
if (_data.GetString(RetailDialogProperty.TextInputAcceptLabel) is { } acceptLabel)
_accept.Label = acceptLabel;
if (_data.GetString(RetailDialogProperty.TextInputRejectLabel) is { } rejectLabel)
_reject.Label = rejectLabel;
Root.Cancel = Reject;
_accept.OnClick = Accept;
_reject.OnClick = Reject;
_input.OnSubmit = _ => Accept();
SetMessage(_data.GetString(RetailDialogProperty.Message) ?? string.Empty);
SizeAndCenter();
}
public UiDialogRoot Root { get; }
public void Tick()
{
SizeAndCenter();
if (_focusPending && Root.Parent is not null)
{
_host.SetKeyboardFocus(_input);
_focusPending = false;
}
}
public void SetPendingCount(int count)
{
// This catalog root authors no pending-count display.
}
public void DetachHandlers()
{
Root.Cancel = null;
_accept.OnClick = null;
_reject.OnClick = null;
_input.OnSubmit = null;
}
private void Accept()
{
_data.Set(RetailDialogProperty.TextInputResult, _input.Text);
_closeDialog(_context);
}
private void Reject()
{
_data.Set(RetailDialogProperty.TextInputResult, string.Empty);
_closeDialog(_context);
}
private void SetMessage(string text)
{
float maximumWidth = Math.Max(1f, _message.Width - 2f * _message.Padding);
Func<string, float> measure = _message.DatFont is { } font
? font.MeasureWidth
: static value => value.Length * 8f;
IReadOnlyList<string> wrapped = UiText.WrapWords(text, measure, maximumWidth);
var lines = new UiText.Line[wrapped.Count];
for (int i = 0; i < wrapped.Count; i++)
lines[i] = new UiText.Line(wrapped[i], _message.DefaultColor);
_message.LinesProvider = () => lines;
float lineHeight = _message.DatFont?.LineHeight ?? 16f;
_message.Height = Math.Max(_baseMessageHeight, lines.Length * lineHeight);
_popup.Height = _basePopupHeight + (_message.Height - _baseMessageHeight);
}
private void SizeAndCenter()
{
// Center against the layout space (fixed canvas while a pre-world
// screen is active) — see RetailConfirmationDialogView.SizeAndCenter.
var space = _host.EffectiveCanvasSize;
Root.Left = 0f;
Root.Top = 0f;
Root.Width = space.X;
Root.Height = space.Y;
_popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f);
_popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f);
}
}

View file

@ -11,6 +11,9 @@ public static class RetailDialogProperty
public const uint AcceptLabel = 0x90u;
public const uint RejectLabel = 0x91u;
public const uint ConfirmationResult = 0x92u;
public const uint TextInputAcceptLabel = 0x9Au;
public const uint TextInputRejectLabel = 0x9Bu;
public const uint TextInputResult = 0x9Cu;
/// <summary>
/// When true, <c>Dialog::SetData @ 0x00476BE0</c> sets UIElement boolean
/// attribute <c>0x40</c>. The Keystone-owned attribute name is unavailable.
@ -105,11 +108,16 @@ public sealed class RetailDialogData
return clone;
}
/// <summary>Type-1 confirmation data. Sets element attribute 0x40 — retail's
/// own confirmation builders do (e.g. <c>MakeConfirmExitDialog @0x004ed250</c>
/// writes 0x8E=1, 0xAC=1, 0xC5=message), same as the Wait/TextInput factories
/// below (gate-round-2 batch review F5).</summary>
public static RetailDialogData Confirmation(string message)
{
ArgumentNullException.ThrowIfNull(message);
return new RetailDialogData()
.Set(RetailDialogProperty.Type, RetailDialogType.Confirmation)
.Set(RetailDialogProperty.ElementAttribute40, true)
.Set(RetailDialogProperty.Message, message);
}
@ -123,4 +131,21 @@ public sealed class RetailDialogData
.Set(RetailDialogProperty.ElementAttribute40, true)
.Set(RetailDialogProperty.Message, message);
}
public static RetailDialogData Message(string message)
{
ArgumentNullException.ThrowIfNull(message);
return new RetailDialogData()
.Set(RetailDialogProperty.Type, RetailDialogType.Message)
.Set(RetailDialogProperty.Message, message);
}
public static RetailDialogData ConfirmationTextInput(string message)
{
ArgumentNullException.ThrowIfNull(message);
return new RetailDialogData()
.Set(RetailDialogProperty.Type, RetailDialogType.ConfirmationTextInput)
.Set(RetailDialogProperty.ElementAttribute40, true)
.Set(RetailDialogProperty.Message, message);
}
}

View file

@ -15,6 +15,7 @@ public sealed class RetailDialogFactory : IDisposable
public required RetailDialogData Data { get; init; }
public required uint Context { get; init; }
public required uint QueueKey { get; init; }
public required ulong Sequence { get; init; }
public Action<RetailDialogData>? Callback { get; init; }
public IRetailDialogView? View { get; set; }
}
@ -24,8 +25,10 @@ public sealed class RetailDialogFactory : IDisposable
private readonly Dictionary<uint, DialogInfo> _activeQueued = new();
private readonly Dictionary<uint, DialogInfo> _activeNonQueued = new();
private readonly Dictionary<uint, LinkedList<DialogInfo>> _pending = new();
private readonly LinkedList<DialogInfo> _retryable = new();
private readonly List<DialogInfo> _openOrder = new();
private uint _globalContext;
private ulong _globalSequence;
private bool _resetting;
private bool _disposed;
@ -51,6 +54,8 @@ public sealed class RetailDialogFactory : IDisposable
public int PendingCount => _pending.Values.Sum(static queue => queue.Count);
internal int RetryCount => _retryable.Count;
/// <summary>Exact root-element switch from <c>CreateDialog_ @ 0x00477AD0</c>.</summary>
public static uint RootElementId(RetailDialogType type)
=> type switch
@ -87,25 +92,40 @@ public sealed class RetailDialogFactory : IDisposable
Data = ownedData,
Context = context,
QueueKey = queueKey,
Sequence = NextSequence(),
Callback = callback,
};
if (queueKey == NonQueuedKey)
{
_activeNonQueued.Add(context, info);
CreateDialog(info);
if (!TryCreateDialog(info))
{
_activeNonQueued.Remove(context);
QueueRetry(info);
}
return context;
}
if (!_activeQueued.TryGetValue(queueKey, out DialogInfo? current))
{
if (HasRetry(queueKey) && !IsPriority(info))
{
PendingQueue(queueKey).AddLast(info);
return context;
}
_activeQueued.Add(queueKey, info);
CreateDialog(info);
if (!TryCreateDialog(info))
{
_activeQueued.Remove(queueKey);
QueueRetry(info);
}
return context;
}
LinkedList<DialogInfo> queue = PendingQueue(queueKey);
if (!ownedData.GetBoolean(RetailDialogProperty.Priority))
if (!IsPriority(info))
{
queue.AddLast(info);
UpdatePendingDialogDisplays();
@ -118,7 +138,15 @@ public sealed class RetailDialogFactory : IDisposable
Suspend(current);
queue.AddFirst(current);
_activeQueued[queueKey] = info;
CreateDialog(info);
if (!TryCreateDialog(info))
{
_activeQueued.Remove(queueKey);
queue.Remove(current);
if (queue.Count == 0)
_pending.Remove(queueKey);
OpenSpecificDialog(current);
QueueRetry(info);
}
return context;
}
@ -148,6 +176,26 @@ public sealed class RetailDialogFactory : IDisposable
return MakeDialog(data, callback: null);
}
public uint MakeMessage(
string message,
Action<RetailDialogData>? callback = null,
uint queueKey = DefaultQueueKey)
{
RetailDialogData data = RetailDialogData.Message(message)
.Set(RetailDialogProperty.QueueKey, queueKey);
return MakeDialog(data, callback);
}
public uint MakeConfirmationTextInput(
string message,
Action<RetailDialogData>? callback = null,
uint queueKey = DefaultQueueKey)
{
RetailDialogData data = RetailDialogData.ConfirmationTextInput(message)
.Set(RetailDialogProperty.QueueKey, queueKey);
return MakeDialog(data, callback);
}
/// <summary>
/// Retail <c>CloseDialog @ 0x00478160</c>. The context can identify an active
/// nonqueued dialog, an active queued dialog, or an item still pending in a queue.
@ -191,13 +239,69 @@ public sealed class RetailDialogFactory : IDisposable
return true;
}
LinkedListNode<DialogInfo>? retry = _retryable.First;
while (retry is not null && retry.Value.Context != context)
retry = retry.Next;
if (retry is not null)
{
DialogInfo failed = retry.Value;
_retryable.Remove(retry);
DialogDone(failed);
if (failed.QueueKey != NonQueuedKey)
OpenNextDialog(failed.QueueKey);
return true;
}
return false;
}
/// <summary>
/// GF-15 fix (Campaign CC gate round 1, Batch A, 2026-08-16). Live-repro-
/// confirmed root cause: <c>CharacterCreationUiController.Tick</c> and
/// <c>CharacterManagementUiController.Tick</c> both call
/// <c>UiRoot.BringToFront(Root)</c> UNCONDITIONALLY on every frame while
/// their screen is open — a per-tick "stay on top of my sibling screen"
/// assertion (needed so chargen never bleeds input to the occluded
/// char-management screen underneath it, register AP-229). A dialog this
/// factory opens is ALSO a direct sibling of those screen roots under
/// the same <c>UiRoot</c> (<c>_host.AddChild(view.Root)</c> in
/// <see cref="TryCreateDialog"/>), competing for the SAME z-order slot.
/// <see cref="RetailWindowManager.BringToFront"/> is a simple "highest
/// ZOrder among <c>_root</c>'s direct children + 1" — whichever sibling's
/// own <c>BringToFront</c> call runs LAST in a frame wins the top slot.
/// Before this fix, this method never re-asserted a dialog's own
/// z-order after the one-time raise in <see cref="TryCreateDialog"/>, so
/// the VERY NEXT frame's screen <c>Tick()</c> (which always runs before
/// this factory's own <c>Tick()</c> in
/// <c>RetailUiRuntime.Tick(double)</c>'s per-frame sequence) silently
/// buried the dialog behind the screen's opaque backdrop — while the
/// dialog remained the registered <see cref="UiRoot.Modal"/> and kept
/// EXCLUSIVE input priority (<c>OnMouseDown</c>'s Modal-vs-bounds gate is
/// independent of render/z-order). The user-visible symptom: press
/// Finish empty → the NoName dialog is created successfully
/// (<c>visible=true</c>, correct geometry, live-DAT-probe-confirmed) but
/// renders NOTHING, and every subsequent click across the WHOLE canvas
/// resolves to the invisible dialog root instead of the name field or
/// Finish button underneath — both GF-15 symptoms from one mechanism.
/// Retail's real dialogs are always-on-top overlays by construction (a
/// separate presentation layer, not a z-ordered sibling of the game UI);
/// re-asserting every open dialog's z-order here, every tick, in
/// <see cref="_openOrder"/> order (so the MOST RECENTLY opened dialog —
/// the same one <see cref="RefreshModal"/> already treats as
/// authoritative — ends up on top) reproduces that invariant without
/// touching either screen controller's own already-verified raise.
/// </summary>
public void Tick()
{
RetryFailedDialogs();
foreach (DialogInfo info in _openOrder.ToArray())
info.View?.Tick();
{
if (info.View is { } view)
{
_host.BringToFront(view.Root);
view.Tick();
}
}
}
/// <summary>
@ -218,6 +322,7 @@ public sealed class RetailDialogFactory : IDisposable
DialogInfo[] infos = _activeNonQueued.Values
.Concat(_activeQueued.Values)
.Concat(_pending.Values.SelectMany(static queue => queue))
.Concat(_retryable)
.Distinct()
.ToArray();
if (infos.Length == 0)
@ -229,6 +334,7 @@ public sealed class RetailDialogFactory : IDisposable
_activeNonQueued.Clear();
_activeQueued.Clear();
_pending.Clear();
_retryable.Clear();
foreach (DialogInfo info in infos)
{
try { DialogDone(info); }
@ -263,6 +369,14 @@ public sealed class RetailDialogFactory : IDisposable
return _globalContext;
}
private ulong NextSequence()
{
_globalSequence++;
if (_globalSequence == 0uL)
_globalSequence++;
return _globalSequence;
}
private LinkedList<DialogInfo> PendingQueue(uint queueKey)
{
if (_pending.TryGetValue(queueKey, out LinkedList<DialogInfo>? queue))
@ -272,19 +386,35 @@ public sealed class RetailDialogFactory : IDisposable
return queue;
}
private void CreateDialog(DialogInfo info)
private bool TryCreateDialog(DialogInfo info)
{
RetailDialogType type = (RetailDialogType)info.Data.GetUInt32(
RetailDialogProperty.Type);
try
{
if (type is not (RetailDialogType.Confirmation
or RetailDialogType.Wait
or RetailDialogType.Message
or RetailDialogType.ConfirmationTextInput))
{
RetailDialogType type = (RetailDialogType)info.Data.GetUInt32(RetailDialogProperty.Type);
if (type is not (RetailDialogType.Confirmation or RetailDialogType.Wait))
throw new NotSupportedException(
$"Retail dialog type {(uint)type} does not have a ported presenter yet.");
}
ImportedLayout layout = _createLayout(type)
?? throw new InvalidOperationException(
$"Retail dialog catalog could not create type {(uint)type}.");
IRetailDialogView view = type switch
{
RetailDialogType.Wait => new RetailWaitDialogView(_host, layout, info.Data),
RetailDialogType.Wait => new RetailWaitDialogView(
_host, layout, info.Data),
RetailDialogType.Message => new RetailMessageDialogView(
_host, layout, info.Data, info.Context,
context => CloseDialog(context)),
RetailDialogType.ConfirmationTextInput =>
new RetailConfirmationTextInputDialogView(
_host, layout, info.Data, info.Context,
context => CloseDialog(context)),
_ => new RetailConfirmationDialogView(
_host, layout, info.Data, info.Context,
context => CloseDialog(context)),
@ -294,8 +424,19 @@ public sealed class RetailDialogFactory : IDisposable
_host.BringToFront(view.Root);
_openOrder.Add(info);
_host.Modal = view.Root;
view.Tick();
UpdatePendingDialogDisplays();
DialogOpened?.Invoke(info.Context);
return true;
}
catch (Exception error)
{
RemoveView(info);
Console.WriteLine(
$"[UI] retail dialog type {(uint)type} context {info.Context} "
+ $"will retry after catalog recovery: {error.Message}");
return false;
}
}
private void Suspend(DialogInfo info)
@ -349,6 +490,9 @@ public sealed class RetailDialogFactory : IDisposable
if (_activeQueued.ContainsKey(queueKey))
return;
if (TryActivateRetry(queueKey))
return;
if (!_pending.TryGetValue(queueKey, out LinkedList<DialogInfo>? queue)
|| queue.First is null)
return;
@ -358,7 +502,115 @@ public sealed class RetailDialogFactory : IDisposable
if (queue.Count == 0)
_pending.Remove(queueKey);
_activeQueued.Add(queueKey, next);
CreateDialog(next);
if (!TryCreateDialog(next))
{
_activeQueued.Remove(queueKey);
QueueRetry(next);
}
}
private void OpenSpecificDialog(DialogInfo info)
{
_activeQueued.Add(info.QueueKey, info);
if (!TryCreateDialog(info))
{
_activeQueued.Remove(info.QueueKey);
QueueRetry(info);
}
}
private void RetryFailedDialogs()
{
foreach (DialogInfo info in _retryable.ToArray())
{
if (info.QueueKey == NonQueuedKey)
{
_activeNonQueued.Add(info.Context, info);
if (TryCreateDialog(info))
_retryable.Remove(info);
else
_activeNonQueued.Remove(info.Context);
continue;
}
if (!ReferenceEquals(FirstRetry(info.QueueKey), info))
continue;
if (!_activeQueued.TryGetValue(
info.QueueKey,
out DialogInfo? active))
TryActivateRetry(info.QueueKey);
else if (IsPriority(info)
&& (!IsPriority(active) || info.Sequence > active.Sequence))
TryPreemptWithRetry(info, active);
}
}
private void TryPreemptWithRetry(DialogInfo priority, DialogInfo current)
{
LinkedList<DialogInfo> queue = PendingQueue(priority.QueueKey);
Suspend(current);
queue.AddFirst(current);
_activeQueued[priority.QueueKey] = priority;
_retryable.Remove(priority);
if (TryCreateDialog(priority))
return;
_activeQueued.Remove(priority.QueueKey);
queue.Remove(current);
if (queue.Count == 0)
_pending.Remove(priority.QueueKey);
OpenSpecificDialog(current);
QueueRetry(priority);
}
private bool TryActivateRetry(uint queueKey)
{
DialogInfo? info = FirstRetry(queueKey);
if (info is null)
return false;
_activeQueued.Add(queueKey, info);
if (TryCreateDialog(info))
_retryable.Remove(info);
else
_activeQueued.Remove(queueKey);
return true;
}
private DialogInfo? FirstRetry(uint queueKey)
{
foreach (DialogInfo info in _retryable)
if (info.QueueKey == queueKey)
return info;
return null;
}
private bool HasRetry(uint queueKey) => FirstRetry(queueKey) is not null;
private static bool IsPriority(DialogInfo info) =>
info.Data.GetBoolean(RetailDialogProperty.Priority);
private void QueueRetry(DialogInfo info)
{
if (_retryable.Contains(info))
return;
if (!IsPriority(info))
{
_retryable.AddLast(info);
return;
}
LinkedListNode<DialogInfo>? existing = _retryable.First;
while (existing is not null
&& existing.Value.QueueKey != info.QueueKey)
{
existing = existing.Next;
}
if (existing is null)
_retryable.AddLast(info);
else
_retryable.AddBefore(existing, info);
}
private void UpdatePendingDialogDisplays()

View file

@ -0,0 +1,108 @@
namespace AcDream.App.UI.Layout;
/// <summary>
/// Retail type-3 <c>MessageDialog</c> (class type <c>0x17</c>, catalog root
/// <c>0x24</c>). It shares the dialog catalog's popup/message pair with the
/// existing confirmation and wait presenters and closes from its authored OK
/// button <c>0x26</c> or Escape.
/// </summary>
internal sealed class RetailMessageDialogView : IRetailDialogView
{
public const uint RootElementId = 0x24u;
public const uint OkButtonId = 0x26u;
public const uint PopupElementId = 0x3Du;
public const uint MessageElementId = 0x3Eu;
private readonly UiRoot _host;
private readonly uint _context;
private readonly Action<uint> _closeDialog;
private readonly UiElement _popup;
private readonly UiText _message;
private readonly UiButton _ok;
private readonly float _basePopupHeight;
private readonly float _baseMessageHeight;
public RetailMessageDialogView(
UiRoot host,
ImportedLayout layout,
RetailDialogData data,
uint context,
Action<uint> closeDialog)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(data);
_context = context;
_closeDialog = closeDialog ?? throw new ArgumentNullException(nameof(closeDialog));
Root = layout.Root as UiDialogRoot
?? throw new ArgumentException("Message layout root is not a UiDialogRoot.", nameof(layout));
_popup = layout.FindElement(PopupElementId)
?? throw new ArgumentException("Message layout is missing popup element 0x3D.", nameof(layout));
_message = layout.FindElement(MessageElementId) as UiText
?? throw new ArgumentException("Message layout is missing text element 0x3E.", nameof(layout));
_ok = layout.FindElement(OkButtonId) as UiButton
?? throw new ArgumentException("Message layout is missing OK button 0x26.", nameof(layout));
_basePopupHeight = _popup.Height;
_baseMessageHeight = _message.Height;
_popup.LayoutPolicy = null;
_popup.Anchors = AnchorEdges.None;
_message.LayoutPolicy = null;
_message.Anchors = AnchorEdges.None;
_message.Padding = 0f;
_message.Selectable = false;
Root.Cancel = Close;
_ok.OnClick = Close;
SetMessage(data.GetString(RetailDialogProperty.Message) ?? string.Empty);
SizeAndCenter();
}
public UiDialogRoot Root { get; }
public void Tick() => SizeAndCenter();
public void SetPendingCount(int count)
{
// MessageDialog has no pending-count subtree in the retail catalog.
}
public void DetachHandlers()
{
Root.Cancel = null;
_ok.OnClick = null;
}
private void Close() => _closeDialog(_context);
private void SetMessage(string text)
{
float maximumWidth = Math.Max(1f, _message.Width - 2f * _message.Padding);
Func<string, float> measure = _message.DatFont is { } font
? font.MeasureWidth
: static value => value.Length * 8f;
IReadOnlyList<string> wrapped = UiText.WrapWords(text, measure, maximumWidth);
var lines = new UiText.Line[wrapped.Count];
for (int i = 0; i < wrapped.Count; i++)
lines[i] = new UiText.Line(wrapped[i], _message.DefaultColor);
_message.LinesProvider = () => lines;
float lineHeight = _message.DatFont?.LineHeight ?? 16f;
_message.Height = Math.Max(_baseMessageHeight, lines.Length * lineHeight);
_popup.Height = _basePopupHeight + (_message.Height - _baseMessageHeight);
}
private void SizeAndCenter()
{
// Center against the layout space (fixed canvas while a pre-world
// screen is active) — see RetailConfirmationDialogView.SizeAndCenter.
var space = _host.EffectiveCanvasSize;
Root.Left = 0f;
Root.Top = 0f;
Root.Width = space.X;
Root.Height = space.Y;
_popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f);
_popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f);
}
}

View file

@ -95,10 +95,15 @@ internal sealed class RetailWaitDialogView : IRetailDialogView
private void SizeAndCenter()
{
// Center against the layout space (fixed canvas while a pre-world
// screen is active) — gate-round-2 batch review F1: this was the ONE
// dialog view the 0a7dc7d6 sweep missed, and it fires on ENTER (the
// char screen's primary action), centering off the visible canvas.
var space = _host.EffectiveCanvasSize;
Root.Left = 0f;
Root.Top = 0f;
Root.Width = _host.Width;
Root.Height = _host.Height;
Root.Width = space.X;
Root.Height = space.Y;
_popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f);
_popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f);
}

View file

@ -179,6 +179,15 @@ public class UiDatElement : UiElement, IUiDatStateful
/// <summary>Label color (default white).</summary>
public Vector4 LabelColor { get; set; } = Vector4.One;
/// <summary>
/// Campaign CC gate round 1 closeout (Group 1, R2-5): per-instance
/// multiplicative sprite tint, threaded into both <see cref="UiRenderContext.DrawSprite"/>
/// calls this class makes (the runtime-image path and the ordinary
/// authored-media path) — same shape and same default-identity
/// no-op-for-existing-callers guarantee as <see cref="UiButton.Tint"/>.
/// </summary>
public Vector4 Tint { get; set; } = Vector4.One;
/// <summary>Retail LayoutDesc property <c>0x21</c> (two-pass glyph outline,
/// <c>UIElement_Text::SetOutline @0x0046a81c</c>). Seeded in the ctor from the
/// element's effective-default state, same as <see cref="UiText.Outline"/>
@ -205,6 +214,56 @@ public class UiDatElement : UiElement, IUiDatStateful
/// </summary>
public uint? RuntimeImageTexture { get; set; }
/// <summary>
/// Retail background-blit ground truth (Campaign LA gate round 2, register
/// AD-98). Every element draws its own media with the native-pixel TILE
/// formula below — retail has no per-element stretch, and neither do we.
///
/// <para>
/// <b>Campaign LA gate round 2 (issue found in the live client: the LA8
/// character-select background repeated across the window instead of scaling
/// with it).</b> Retail's generic UI sprite blit —
/// <c>Graphic::Draw</c> (acclient 0x00693b20) dispatching to
/// <c>Graphic::PutImage</c> (0x00693a30) for an exact/undersized destination, or a
/// modulo-wrapped tile loop otherwise — has exactly two behaviors, copy or tile;
/// it can never scale a source image up to a larger destination. This is confirmed
/// against two candidate "draw-mode" fields that could have carried a stretch bit
/// and don't: <c>BlitMode</c> (acclient.h ~line 3135 — Blit_Normal/3Alpha/4Alpha/
/// Colorize/Multiply/Screen/Grayscale/NOP are all COLOR-BLEND selectors) and
/// <c>MD_Data_Image::m_drawMode</c>/<c>DrawModeType</c> (Undefined/Normal/Overlay/
/// Alphablend — also a blend selector; the "Normal → tile" reading in
/// <c>docs/research/2026-06-15-layoutdesc-format.md</c> §6 cited
/// <c>ImgTex::TileCSI</c> (0x0053e740), but that function is exclusively called from
/// <c>TexMerge::CopyAndTile</c>/<c>ImgTex::CopyCSI</c> for LAND-SURFACE terrain
/// texture compositing (<c>TerrainTex</c>) — never from the UI element system; the
/// citation was a coincidental name match, not the real call site).
/// </para>
///
/// <para>
/// The LA8 root itself (0x1000039A) authors LeftEdge=TopEdge=RightEdge=BottomEdge=0
/// ("no anchor" — confirmed against the installed DAT via
/// <c>CharacterManagementLiveDatTests.RootAuthorsNoEdgeAnchors_RetailNeverResizesItSelf</c>),
/// so retail's own <c>UIElement::UpdateForParentSizeChange</c> (0x00462640) never
/// touches this element's size at all — it stays a fixed 800x600 rect. The only way
/// retail's whole pre-world "flow" scene (background AND buttons AND listbox
/// together — "the background scales with the root") can still fill an arbitrary
/// window resolution edge-to-edge, with the generic sprite blit only ever able to
/// copy-or-tile, is that these screens render into a fixed, authored-size (800x600)
/// target and the WHOLE FRAME is stretched once at presentation — a step entirely
/// outside the UIRegion/<c>Graphic::Draw</c> sprite system.
/// </para>
///
/// <para>
/// acdream's equivalent of that present-time stretch is
/// <see cref="AcDream.App.UI.UiRoot.FixedCanvasSize"/>: while a fixed-canvas
/// screen (char select) is active, the WHOLE retained tree — this tile draw
/// included — is scaled uniformly at the renderer's quad chokepoint, with the
/// inverse applied to mouse input. Elements therefore keep their authored
/// canvas-space sizes here, and the tile formula stays exactly retail's:
/// inside the authored canvas an element never exceeds its media's native
/// span unless retail itself tiled it.
/// </para>
/// </summary>
protected override void OnDraw(UiRenderContext ctx)
{
if (MediaVisible && RuntimeImageTexture is uint runtimeTexture)
@ -221,7 +280,7 @@ public class UiDatElement : UiElement, IUiDatStateful
0f,
1f,
1f,
Vector4.One);
Tint);
}
DrawLabel(ctx);
return;
@ -233,10 +292,14 @@ public class UiDatElement : UiElement, IUiDatStateful
var (tex, tw, th) = _resolve(file);
if (tex != 0 && tw != 0 && th != 0)
{
// Normal → TILE at native size on both axes (UV-repeat; GL_REPEAT-wrapped UI
// texture), matching ImgTex::TileCSI. Overlay/Alphablend use the same blit (the
// sprite shader already alpha-blends). No Stretch mode exists in DrawModeType.
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
// TILE at native size on both axes (UV-repeat; GL_REPEAT-wrapped
// UI texture) — retail's Graphic::Draw/Graphic::PutImage
// (0x00693b20/0x00693a30) copy-or-tile blit; NOT ImgTex::TileCSI,
// which is land-surface-only (corrected citation, see the class
// doc). Overlay/Alphablend use the same blit (the sprite shader
// already alpha-blends). No Stretch mode exists in DrawModeType;
// whole-canvas stretching happens at UiRoot.FixedCanvasSize.
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Tint);
}
}

View file

@ -16,6 +16,7 @@ using AcDream.Core.Selection;
using AcDream.Core.Spells;
using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
using AcDream.Runtime.Session;
using AcDream.Content;
using AcDream.Core.Input;
using AcDream.UI.Abstractions;
@ -369,6 +370,53 @@ public sealed record KeyboardRuntimeBindings(
InputDispatcher? Dispatcher,
string KeyBindingsFilePath);
/// <summary>
/// Borrowed LA7b character-selection projection and its generation-capturing
/// typed command routes. App owns no roster, selection, operation, or error
/// mirror; an absent view means the current adapter has not bound (or has
/// already been released).
/// </summary>
/// <param name="RequestExit">
/// Campaign LA gate round 2 finding 1: retail's Exit button
/// (<c>gmCharacterManagementUI::ListenToElementMessage@0x004ed5a0</c>,
/// element offset 7 from the listbox base — id <c>0x100003A4</c>) opens
/// <c>MakeConfirmExitDialog@0x004ed250</c>; on confirm
/// (<c>RecvNotice_CloseDialog@0x004ed760</c> case 1) retail queues UI mode
/// <c>0x10000009</c> (<c>gmEpilogueUI</c>) rather than exiting immediately —
/// out of scope here. This is a plain host action, not a generation-gated
/// Runtime command: it is the SAME window-close path
/// <c>GameplayWindowCommands</c>/<c>IGameplayWindowCommands.Close</c> already
/// use for the in-world Escape fallback (<c>d.Window.Close</c> at
/// composition), so status events <c>disconnected</c>/<c>exited</c> still
/// fire through <c>GameWindow.OnClosing</c> → <c>CompleteShutdown</c>.
/// </param>
public sealed record CharacterSelectionRuntimeBindings(
Func<IRuntimeCharacterSelectionView?> View,
Func<uint, RuntimeCommandResult> Highlight,
Func<RuntimeCommandResult> Enter,
Func<RuntimeCommandResult> RequestDelete,
Func<RuntimeCommandResult> ConfirmDelete,
Func<RuntimeCommandResult> Restore,
Func<RuntimeCommandResult> Cancel,
Action RequestExit,
/// <summary>
/// Campaign CC slice CC7: retail's Create button
/// (<c>gmCharacterManagementUI::ListenToElementMessage @ 0x004ed5a0</c>
/// case 3 -&gt; <c>UIFramework::QueueUIMode(this, 0x1000000b)</c>, the
/// <c>gmCharGenMainUI</c> mode). Wired by <see cref="RetailUiRuntime"/>
/// itself (it alone holds both the character-management and
/// character-creation controllers) to
/// <c>CharacterCreationController?.Open()</c> — resolved lazily so
/// mount order between the two screens does not matter.
/// <see langword="null"/> when no chargen screen is mounted (e.g. a
/// headless bot's <c>LiveCharacterSelector</c> path, where
/// <see cref="RetailUiRuntimeBindings.CharacterCreation"/> is also
/// null) — the button then behaves as a no-op click while its own
/// <c>Enabled</c> gate (<see cref="RuntimeCharacterSelectionButtons.CanCreate"/>)
/// still reflects the real roster-vs-slot state.
/// </summary>
Action? RequestCreate = null);
public sealed record RetailUiRuntimeBindings(
UiHost Host,
RetailUiAssets Assets,
@ -395,7 +443,10 @@ public sealed record RetailUiRuntimeBindings(
BufferedUiRegistry? Plugins,
RetailUiPersistenceBindings? Persistence,
RetailUiProbeBindings Probe,
KeyboardRuntimeBindings? Keyboard = null);
KeyboardRuntimeBindings? Keyboard = null,
CharacterSelectionRuntimeBindings? CharacterSelection = null,
// Campaign CC slice CC4: sibling of CharacterSelection above.
CharacterCreationRuntimeBindings? CharacterCreation = null);
/// <summary>
/// Composition owner for the production retained gameplay UI. GameWindow supplies
@ -417,6 +468,8 @@ public sealed class RetailUiRuntime : IDisposable
private UiShortcutDigitGraphics? _shortcutDigitGraphics;
private ItemCooldownUiController? _itemCooldownController;
private VividTargetIndicatorController? _vividTargetIndicator;
private CharacterManagementUiMountCoordinator? _characterManagementMount;
private CharacterCreationUiMountCoordinator? _characterCreationMount;
private IDisposable? _characterSheetSubscription;
private ResourceShutdownTransaction? _shutdown;
private bool _disposed;
@ -483,6 +536,10 @@ public sealed class RetailUiRuntime : IDisposable
MountVendor();
MountSecureTrade();
MountItemCooldowns();
ConfigureCharacterManagement();
_characterManagementMount?.Tick();
ConfigureCharacterCreation();
_characterCreationMount?.Tick();
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
BindToolbarPanelButtons();
SyncToolbarWindowButtons();
@ -577,6 +634,134 @@ public sealed class RetailUiRuntime : IDisposable
public VendorUiController? VendorController { get; private set; }
public OptionsPanelController? OptionsPanelController { get; private set; }
public SocialPanelController? SocialPanelController { get; private set; }
internal CharacterManagementUiController? CharacterManagementController =>
_characterManagementMount?.Controller;
internal CharacterCreationUiController? CharacterCreationController =>
_characterCreationMount?.Controller;
/// <summary>Campaign CC slice CC6b-MOUNT: the Appearance page's authored
/// viewport (<c>0x100003bb</c>) — null until the screen has mounted.
///
/// <para>
/// Fix round F8 correction: this is NOT the same shape as
/// <see cref="PaperdollViewportWidget"/> — that one is a plain
/// <c>{ get; private set; }</c> auto-property assigned exactly once,
/// eagerly and non-retryably, inside <c>MountInventory()</c> (itself
/// called synchronously from <c>Initialize()</c>; if it fails the whole
/// <see cref="Mount"/> call throws and the WHOLE UI runtime fails to
/// construct — there is no partial-failure case where
/// <see cref="PaperdollViewportWidget"/> stays null while the rest of
/// the runtime comes up). THIS property is computed-through specifically
/// BECAUSE its underlying mount, <c>_characterCreationMount</c>
/// (<see cref="CharacterCreationUiMountCoordinator"/>), is explicitly
/// retryable/idempotent — ticked once per frame via <see cref="Tick"/>
/// until it succeeds, tolerating a DAT/resource read that isn't ready
/// yet without failing the rest of the UI. <see cref="AcDream.App.Composition.LivePresentationComposition"/>
/// reads this property EXACTLY ONCE, during the single synchronous
/// startup composition pass (<c>GameWindow.OnLoad</c>) — unlike the
/// coordinator's own per-frame <c>Tick</c>, that one-shot GPU-resource
/// composition pass is NOT retried, matching every other private
/// viewport binding in that same method (paperdoll, creature appraisal)
/// — see that call site's own comment for the full disposition.
/// </para>
/// </summary>
internal UiViewport? ChargenPreviewViewportWidget =>
CharacterCreationController?.AppearanceViewport;
/// <summary>CC6b-MOUNT: the late-bound zoom/rotate control surface the
/// composition root assigns once the graphics backend exists.</summary>
internal AcDream.App.Rendering.IChargenPreviewControl? ChargenPreviewControl
{
get => CharacterCreationController?.AppearancePreviewControl;
set
{
if (CharacterCreationController is { } controller)
controller.AppearancePreviewControl = value;
}
}
/// <summary>Campaign CC gate round 1 closeout (Group 1, R2-5): the same
/// late-bound pattern as <see cref="ChargenPreviewControl"/> above, for
/// the real color-wheel/swatch-color mechanism's three DAT-backed seams
/// — see <see cref="AcDream.App.UI.Layout.CharacterCreationAppearancePage.PalSetSource"/>'s
/// own doc comment.</summary>
internal AcDream.Core.CharGen.IChargenPalSetSource? ChargenPalSetSource
{
get => CharacterCreationController?.AppearancePalSetSource;
set
{
if (CharacterCreationController is { } controller)
controller.AppearancePalSetSource = value;
}
}
internal AcDream.Core.CharGen.IChargenClothingTableSource? ChargenClothingTableSource
{
get => CharacterCreationController?.AppearanceClothingTableSource;
set
{
if (CharacterCreationController is { } controller)
controller.AppearanceClothingTableSource = value;
}
}
internal AcDream.Core.CharGen.IChargenPaletteColorSource? ChargenPaletteColorSource
{
get => CharacterCreationController?.AppearancePaletteColorSource;
set
{
if (CharacterCreationController is { } controller)
controller.AppearancePaletteColorSource = value;
}
}
/// <summary>R3-5/R3-6 (Campaign CC gate round 1 re-test 2): the fourth
/// late-bound seam, same pattern as the three above — see
/// <see cref="AcDream.App.UI.Layout.CharacterCreationAppearancePage.SwatchTextureSource"/>'s
/// own doc comment.</summary>
internal AcDream.App.UI.Layout.IChargenSwatchTextureSource? ChargenSwatchTextureSource
{
get => CharacterCreationController?.AppearanceSwatchTextureSource;
set
{
if (CharacterCreationController is { } controller)
controller.AppearanceSwatchTextureSource = value;
}
}
/// <summary>CC6b-MOUNT: whether the Appearance page (specifically) is
/// the one currently showing — false, safely, before the screen mounts.
/// </summary>
internal bool IsChargenPreviewPageVisible =>
CharacterCreationController?.IsAppearancePageVisible ?? false;
/// <summary>Campaign CC slice CC5: the Summary page's OWN authored
/// viewport (<c>0x10000406</c>) — same one-shot GPU-composition
/// disposition as <see cref="ChargenPreviewViewportWidget"/> (see that
/// property's own doc comment; AP-221 covers both).</summary>
internal UiViewport? SummaryPreviewViewportWidget =>
CharacterCreationController?.SummaryViewport;
/// <summary>Campaign CC slice CC5: the Summary preview's late-bound
/// control surface. No zoom/rotate buttons bind against it (retail's
/// Summary page has none) — the composition root assigns it purely so
/// <see cref="AcDream.App.Rendering.ChargenPreviewController.Rebuild"/>
/// gets driven per-selection-change the same way the Appearance
/// preview's is.</summary>
internal AcDream.App.Rendering.IChargenPreviewControl? SummaryPreviewControl
{
get => CharacterCreationController?.SummaryPreviewControl;
set
{
if (CharacterCreationController is { } controller)
controller.SummaryPreviewControl = value;
}
}
/// <summary>Campaign CC slice CC5: whether the Summary page
/// (specifically) is the one currently showing.</summary>
internal bool IsSummaryPreviewPageVisible =>
CharacterCreationController?.IsSummaryPageVisible ?? false;
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
{
@ -622,6 +807,10 @@ public sealed class RetailUiRuntime : IDisposable
ExternalContainerController?.Tick();
SocialPanelController?.Tick();
_itemCooldownController?.Tick();
_characterManagementMount?.Tick();
CharacterManagementController?.Tick();
_characterCreationMount?.Tick();
CharacterCreationController?.Tick();
DialogFactory?.Tick();
Host.Tick(deltaSeconds);
_automation?.Tick(deltaSeconds);
@ -788,6 +977,7 @@ public sealed class RetailUiRuntime : IDisposable
{
try
{
CharacterManagementController?.ResetSession();
DialogFactory?.Reset();
}
finally
@ -2437,7 +2627,12 @@ public sealed class RetailUiRuntime : IDisposable
// default, installed at startup by the graphical host;
// fixture/headless mounts leave the catalog empty and the
// controller falls back to the static preset ladder.
availableResolutions: Rendering.DisplayModeCatalog.Resolutions,
// #407: the dropdown offers the WINDOWED union (hardware
// modes + static-ladder sizes that fit the desktop) — a
// windowed Size write needs no video mode, and remote/RDP
// displays advertise almost none. The fullscreen APPLY
// still validates against the hardware list only.
availableResolutions: Rendering.DisplayModeCatalog.WindowedResolutions,
resolutionDefault: Rendering.DisplayModeCatalog.DesktopResolution);
if (!configBound)
Console.WriteLine("[UI] options panel: Config tab rows did not bind.");
@ -3030,13 +3225,29 @@ public sealed class RetailUiRuntime : IDisposable
private void MountDialogFactory()
{
if (DialogFactory is not null)
return;
uint layoutId;
try
{
lock (_bindings.Assets.DatLock)
{
// DialogFactory::CreateDialog_ @ 0x00477AD0 resolves the shared
// catalog through GetDIDByEnum(2, 5). Each shown DialogInfo then
// creates a fresh type-specific root from that catalog.
layoutId = RetailDataIdResolver.Resolve(_bindings.Assets.Dats, 2u, 5u);
layoutId = RetailDataIdResolver.Resolve(
_bindings.Assets.Dats,
2u,
5u);
}
}
catch (Exception error)
{
Console.WriteLine(
"[UI] retail dialog catalog will retry after resource "
+ $"recovery: {error.Message}");
return;
}
if (layoutId == 0u)
@ -3273,10 +3484,12 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Assets.ResolveSprite,
_bindings.Assets.Controls);
Host.Root.AddChild(element);
_bindings.Plugins.CompleteMount(panel, Host.Root, element);
Console.WriteLine($"[D.2b] plugin UI panel loaded: {panel.MarkupPath}");
}
catch (Exception ex)
{
_bindings.Plugins.FailMount(panel);
Console.WriteLine($"[D.2b] plugin UI panel '{panel.MarkupPath}' failed to load: {ex.Message}");
}
}
@ -3667,6 +3880,271 @@ public sealed class RetailUiRuntime : IDisposable
"[M4] retail secure trade panel mounted from LayoutDesc 0x2100000D.");
}
private void ConfigureCharacterManagement()
{
CharacterSelectionRuntimeBindings? bindings =
_bindings.CharacterSelection;
if (bindings is null || _characterManagementMount is not null)
return;
// Campaign CC slice CC7: RetailUiRuntime is the one object holding
// BOTH controllers, so it supplies the cross-screen seam locally
// rather than routing it through the externally-composed bindings
// record (which is built before this runtime exists — see
// CharacterSelectionRuntimeBindings.RequestCreate's own doc
// comment). The lambda closes over `this` and reads
// CharacterCreationController per call, so it is safe even though
// ConfigureCharacterCreation() has not run yet at this point (see
// its call site immediately below this method's own caller).
_characterManagementMount = new CharacterManagementUiMountCoordinator(
Host.Root,
bindings with { RequestCreate = () => CharacterCreationController?.Open() },
EnsureDialogFactory,
LoadCharacterManagementResources);
}
private RetailDialogFactory? EnsureDialogFactory()
{
MountDialogFactory();
return DialogFactory;
}
private CharacterManagementUiMountResources? LoadCharacterManagementResources()
{
const uint stringTableId = 0x23000002u;
uint layoutId;
ImportedLayout? layout;
var strings = new DatStringResolver(_bindings.Assets.Dats);
lock (_bindings.Assets.DatLock)
{
// gmCharacterManagementUI's framework call passes enum
// 0x10000005 and category/table 5, then selects root 0x1000039A.
layoutId = RetailDataIdResolver.Resolve(
_bindings.Assets.Dats,
CharacterManagementUiController.RootEnum,
5u);
layout = layoutId == 0u
? null
: LayoutImporter.Import(
_bindings.Assets.Dats,
layoutId,
CharacterManagementUiController.RootElementId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
}
if (layout is null)
{
Console.WriteLine(
"[UI] character management: enum-table-5 root could not be imported.");
return null;
}
string? deleteResponse;
string? deleteConfirmationProbe;
string? pleaseWait;
string? enteringWorld;
string? confirmExit;
lock (_bindings.Assets.DatLock)
{
deleteConfirmationProbe = strings.ResolveTemplate(
stringTableId,
"ID_CharacterManagement_DeleteCharacterConfirmation",
new Dictionary<uint, string>
{
[DatStringResolver.PlayerVariable] = string.Empty,
});
deleteResponse = ResolveCharacterManagementString(
strings,
stringTableId,
"ID_CharacterManagement_DeleteCharacterResponse");
pleaseWait = ResolveCharacterManagementString(
strings,
stringTableId,
"ID_CharacterManagement_PleaseWait");
enteringWorld = ResolveCharacterManagementString(
strings,
stringTableId,
"ID_Character_EnteringWorld");
// Finding 1: MakeConfirmExitDialog@0x004ed250 resolves this via
// compute_str_hash("ID_CharacterManagement_ConfirmExit") against
// the same table-enum-0x10000002 -> 0x23000002 the other
// character-management dialogs already use.
confirmExit = ResolveCharacterManagementString(
strings,
stringTableId,
"ID_CharacterManagement_ConfirmExit");
}
if (deleteConfirmationProbe is null
|| deleteResponse is null
|| pleaseWait is null
|| enteringWorld is null
|| confirmExit is null)
{
Console.WriteLine(
"[UI] character management: required retail strings are unavailable.");
return null;
}
UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId)
{
lock (_bindings.Assets.DatLock)
{
return LayoutImporter.Import(
_bindings.Assets.Dats,
templateLayoutId,
templateElementId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont)?.Root;
}
}
string ComposeDeleteConfirmation(string characterName)
{
lock (_bindings.Assets.DatLock)
{
return NormalizeRetailNewlines(strings.ResolveTemplate(
stringTableId,
"ID_CharacterManagement_DeleteCharacterConfirmation",
new Dictionary<uint, string>
{
[DatStringResolver.PlayerVariable] = characterName,
})!);
}
}
return new CharacterManagementUiMountResources(
layoutId,
layout,
ResolveTemplate,
new CharacterManagementUiController.DialogStrings(
ComposeDeleteConfirmation,
deleteResponse,
pleaseWait,
enteringWorld,
confirmExit));
}
private static string? ResolveCharacterManagementString(
DatStringResolver strings,
uint tableId,
string key) =>
strings.Resolve(tableId, DatStringResolver.ComputeHash(key)) is { } value
? NormalizeRetailNewlines(value)
: null;
private static string NormalizeRetailNewlines(string value) =>
value.Replace("\\n", "\n", StringComparison.Ordinal);
private void ConfigureCharacterCreation()
{
CharacterCreationRuntimeBindings? bindings = _bindings.CharacterCreation;
if (bindings is null || _characterCreationMount is not null)
return;
_characterCreationMount = new CharacterCreationUiMountCoordinator(
Host.Root,
bindings,
EnsureDialogFactory,
LoadCharacterCreationResources);
}
private CharacterCreationUiMountResources? LoadCharacterCreationResources()
{
const uint stringTableId = 0x23000002u;
uint layoutId;
ImportedLayout? layout;
var strings = new DatStringResolver(_bindings.Assets.Dats);
lock (_bindings.Assets.DatLock)
{
// gmCharGenMainUI's framework registration passes enum
// 0x10000039 and category/table 5, then selects root 0x100003CC.
layoutId = RetailDataIdResolver.Resolve(
_bindings.Assets.Dats,
CharacterCreationUiController.RootEnum,
5u);
layout = layoutId == 0u
? null
: LayoutImporter.Import(
_bindings.Assets.Dats,
layoutId,
CharacterCreationUiController.RootElementId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
}
if (layout is null)
{
Console.WriteLine(
"[UI] character creation: enum-table-5 root could not be imported.");
return null;
}
string? exitWarning;
string? noNameWarning;
string? creditWarning;
string? randomizeWarning;
string? nameTooLong;
lock (_bindings.Assets.DatLock)
{
exitWarning = ResolveCharacterManagementString(
strings,
stringTableId,
"ID_CharGen_ExitWarning");
noNameWarning = ResolveCharacterManagementString(
strings,
stringTableId,
"ID_CharGen_NoNameWarning");
creditWarning = ResolveCharacterManagementString(
strings,
stringTableId,
"ID_CharGen_CreditWarning");
randomizeWarning = ResolveCharacterManagementString(
strings,
stringTableId,
"ID_CharGen_RandomizeWarning");
nameTooLong = ResolveCharacterManagementString(
strings,
stringTableId,
"ID_CharGen_NameTooLong");
}
if (exitWarning is null
|| noNameWarning is null
|| creditWarning is null
|| randomizeWarning is null
|| nameTooLong is null)
{
Console.WriteLine(
"[UI] character creation: required retail strings are unavailable.");
return null;
}
UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId)
{
lock (_bindings.Assets.DatLock)
{
return LayoutImporter.Import(
_bindings.Assets.Dats,
templateLayoutId,
templateElementId,
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont)?.Root;
}
}
return new CharacterCreationUiMountResources(
layoutId,
layout,
ResolveTemplate,
new CharacterCreationUiController.DialogStrings(
exitWarning, noNameWarning, creditWarning, randomizeWarning, nameTooLong));
}
private void MountItemCooldowns()
{
ItemCooldownAssets? assets;
@ -3710,7 +4188,12 @@ public sealed class RetailUiRuntime : IDisposable
}
},
() => _itemConfirmationController?.Dispose(),
() => _gameplayConfirmationController?.Dispose(),
() =>
{
_characterManagementMount?.Dispose();
_characterCreationMount?.Dispose();
_gameplayConfirmationController?.Dispose();
},
() => DialogFactory?.Dispose(),
_panelUi.Dispose,
Host.Dispose);

View file

@ -37,6 +37,9 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
private readonly FaceSegment[] _faceSegments;
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
private readonly HashSet<uint> _availableStates = new();
private readonly bool _hasCustomSelectionPair;
private IReadOnlyDictionary<uint, Vector4>? _stateLabelColors;
private IReadOnlyDictionary<uint, bool>? _stateLabelOutlines;
private bool _pressed;
private bool _pointerOver;
private bool _selected;
@ -49,6 +52,13 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
/// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary>
public Action? OnClick { get; set; }
/// <summary>
/// Optional left-button double-click handler. Null preserves the existing
/// bubbling behavior; character-management row template 0x100003A5 opts in
/// for retail's element message 0x1A (activate the selected character).
/// </summary>
public Action? OnDoubleClick { get; set; }
/// <summary>
/// Optional right-click handler (Campaign OP slice OP8's Configure Keyboard
/// screen: right-click a bound key button to erase that one binding —
@ -143,6 +153,50 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
/// </summary>
public uint? FaceFileOverride { get; set; }
/// <summary>
/// Campaign CC gate round 1 closeout (Group 1, R2-5): per-instance
/// multiplicative sprite tint, threaded into every <see cref="UiRenderContext.DrawSprite"/>
/// call this class makes (main face, face-segment, drag-acceptance
/// overlay) — retail's own <c>SurfaceWindow::BlitAndColor(...,
/// Blit_Multiply, color)</c>. Default <see cref="Vector4.One"/> (white,
/// full alpha) leaves every DrawSprite call byte-identical to before
/// this property existed; only a caller that explicitly sets a
/// non-identity tint (e.g. <see cref="Layout.CharacterCreationAppearancePage"/>'s
/// color-wheel swatches) changes what draws.
/// </summary>
public Vector4 Tint { get; set; } = Vector4.One;
/// <summary>
/// R3-5 (Campaign CC gate round 1 re-test 2): optional resolver
/// returning a PRE-BAKED, already color-key-recolored texture handle
/// (from <see cref="AcDream.App.Rendering.TextureCache.UploadRgba8"/>
/// or equivalent), drawn UNTINTED (1:1, no UV repeat) INSTEAD of the
/// ordinary <see cref="FaceFileOverride"/>/<c>ActiveFile</c> sprite.
/// Retail's own <c>gmCGAppearancePage::DoColorSpots @0x0047d850</c>
/// does NOT multiply-tint the swatch's authored ring+spot sprite (a
/// multiply of a target color against BLACK — the spot template's own
/// placeholder fill, live-DAT-pixel-confirmed — stays black regardless
/// of the tint, and multiplying the ring's own non-black border pixels
/// shifts their hue/brightness, corrupting them). Retail instead calls
/// <c>SurfaceWindow::ReplaceColor</c>: build a fresh composited surface
/// once, blit the spot template onto it, then swap every EXACT-black
/// pixel for the swatch's real color — the ring border (never black)
/// is untouched. This property is that same mechanism's C# seam.
/// <see cref="Tint"/> itself is left completely unchanged in meaning
/// and is STILL the value callers set to communicate "this button's
/// color is X" (existing callers/tests that only read
/// <see cref="Tint"/> are unaffected) — this resolver is a SEPARATE
/// decision (deliberately not fed by <see cref="Tint"/>: a caller may
/// need to distinguish more states — e.g. "beyond count, show the
/// blocked art" versus "no color data yet, show nothing" — than one
/// Vector4 can encode) that only changes what OnDraw does when
/// non-null: consult it for a texture instead of directly multiplying
/// the authored sprite. Null (default, every pre-existing button)
/// preserves the exact prior FaceFileOverride/ActiveFile +
/// multiply-Tint draw.
/// </summary>
public Func<uint>? ColorKeyFaceResolver { get; set; }
/// <summary>Additional left inset for left-aligned labels.</summary>
public float LabelOffsetX { get; set; } = 3f;
@ -150,8 +204,74 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
/// Left for the paperdoll "Slots" caption that sits at the left edge, before the slots.</summary>
public LabelAlignment LabelAlign { get; set; } = LabelAlignment.Center;
/// <summary>Label horizontal alignment options.</summary>
public enum LabelAlignment { Center, Left }
/// <summary>
/// GF-11c (Campaign CC gate round 1 Batch B): optional authored label
/// rectangle, LOCAL to this button. When a caption is LIFTED from a
/// DISTINCT Type-12 child that carries its own independent rect (e.g.
/// the Town page's per-marker name label, positioned below/beside its
/// marker rather than immediately right of it), <see cref="OnDraw"/>
/// draws the label within THIS box using its own authored geometry
/// instead of the FaceLeft-derived offset / full-button-width centering
/// the ordinary case uses (label authored directly on the button, right
/// beside a single-purpose face segment — the heritage/template/Face-
/// Clothes row family, where the current face-relative math is already
/// correct). Null (default, every pre-existing button) preserves the
/// EXACT prior draw math — <see cref="LabelAlignment.Left"/> still adds
/// <see cref="LabelOffsetX"/> to the button's own local origin, and
/// <see cref="LabelAlignment.Center"/> still centers within the whole
/// button width/height.
/// </summary>
public (float X, float Y, float Width, float Height)? LabelBox { get; set; }
/// <summary>
/// GF-4a (Campaign CC gate round 1 Batch C): optional secondary VALUE
/// text, coexisting with <see cref="Label"/> (the authored CAPTION).
/// Retail's chargen display buttons (Attribute/Skill Credits, Health,
/// Stamina, Mana — <c>0x100003e2-e5</c>, <c>0x100003f9</c>) author the
/// caption directly as this element's own dat property <c>0x17</c>
/// AND carry a SEPARATE, media-less Type-12 child for the live value
/// (<c>gmCGProfessionPage::InitializePage @0x00482f90-0x00483062</c>,
/// <c>gmCGSkillsPage::InitializePage @0x00481e1c</c>) —
/// <see cref="UiButton"/> consumes ALL of its dat children
/// (<see cref="ConsumesDatChildren"/>), which used to mean a page
/// controller had nowhere faithful to put the value except
/// overwriting <see cref="Label"/> itself, destroying the caption.
/// <see cref="Layout.DatWidgetFactory.BuildButton"/> now surfaces that
/// child's geometry/font/color here instead. Null (default) draws
/// nothing extra — every pre-existing button that only ever wrote
/// <see cref="Label"/> is unaffected.
/// </summary>
public string? ValueLabel { get; set; }
/// <summary>Dat font for <see cref="ValueLabel"/>.</summary>
public UiDatFont? ValueFont { get; set; }
/// <summary>Color for <see cref="ValueLabel"/> (default white).</summary>
public Vector4 ValueColor { get; set; } = Vector4.One;
/// <summary>Authored rectangle for <see cref="ValueLabel"/>, LOCAL to
/// this button — the lifted value child's own rect
/// (<see cref="Layout.DatWidgetFactory.BuildButton"/> sets this). Null
/// (no value child found) means <see cref="ValueLabel"/> is never set
/// either, so this is never read in that case.</summary>
public (float X, float Y, float Width, float Height)? ValueBox { get; set; }
/// <summary>Horizontal alignment of <see cref="ValueLabel"/> within
/// <see cref="ValueBox"/> — the lifted child's own authored justify.</summary>
public LabelAlignment ValueAlign { get; set; } = LabelAlignment.Center;
/// <summary>
/// Label horizontal alignment options. <see cref="Right"/> (R4-1, Campaign
/// CC gate round 1 re-test 3) is ValueLabel-only today — every value
/// child on the chargen credit-display family (0x100002f1/0x100002f3)
/// authors dat HJustify Right (raw 3/5), decomp-confirmed by
/// <c>UIElement_Text::CalcJustification @0x00467260</c>'s
/// <c>ecx_5==3||5</c> branch (<c>edi = availWidth - textWidth</c>, i.e.
/// flush to the box's own far edge) — distinct from Center's halved
/// offset. <see cref="LabelAlign"/> never authors Right today so no
/// existing switch over it needs a new arm.
/// </summary>
public enum LabelAlignment { Center, Left, Right }
public bool ToggleBehavior { get; }
public bool RolloverEnabled { get; }
@ -323,6 +443,21 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
foreach (FaceSegment segment in _faceSegments)
AddAvailableStates(segment.Info);
// Campaign CC gate round 1 Batch B (GF-1/GF-8): retail's custom
// "Unselected"/"Selected" radio-selection state pair
// (RetailUiStateIds.Unselected/Selected, 0x10000016/0x10000017) is
// authored as STATE DESCRIPTORS whose names UiButtonStateMachine's
// Normal/Highlight machine doesn't recognize — the standard
// AddAvailableStates loop above never admits them, so the ordinary
// RequestedState()-driven UpdateVisualState can never select them
// (measured: Selected=true committed nothing against the installed
// dat before this fix). HasStateMedia already checks the same media
// presence (face-segment child OR the button's own StateMedia) used
// everywhere else in this class, so this reuses that exact
// detection rather than adding a new one.
_hasCustomSelectionPair = HasStateMedia(RetailUiStateIds.StateName(RetailUiStateIds.Unselected))
&& HasStateMedia(RetailUiStateIds.StateName(RetailUiStateIds.Selected));
ToggleBehavior = info.TryGetEffectiveBool(0x0Bu, out bool toggle) && toggle;
RolloverEnabled = info.TryGetEffectiveBool(0x13u, out bool rollover) && rollover;
HotClickEnabled = info.TryGetEffectiveBool(0x0Fu, out bool hotClick) && hotClick;
@ -375,6 +510,22 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
foreach (FaceSegment segment in _faceSegments)
DrawFace(ctx, ActiveFile(segment.Info), segment.Rect(Width, Height));
}
else if (ColorKeyFaceResolver is { } colorKeyResolver)
{
// R3-5: a pre-baked, already-recolored texture (see this
// property's own doc) — drawn UNTINTED and 1:1 (no UV repeat;
// the baked bitmap is uploaded at its own native size, which
// for the chargen swatches equals the button's own authored
// rect, live-DAT-measured).
uint bakedTexture = colorKeyResolver();
if (bakedTexture != 0)
{
float faceWidth = FaceWidth > 0f ? FaceWidth : Width;
float faceHeight = FaceHeight > 0f ? FaceHeight : Height;
ctx.DrawSprite(bakedTexture, FaceLeft, FaceTop, faceWidth, faceHeight,
0f, 0f, 1f, 1f, Vector4.One);
}
}
else
{
uint file = FaceFileOverride ?? ActiveFile(_mediaInfo);
@ -388,18 +539,58 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
float faceWidth = FaceWidth > 0f ? FaceWidth : Width;
float faceHeight = FaceHeight > 0f ? FaceHeight : Height;
ctx.DrawSprite(tex, FaceLeft, FaceTop, faceWidth, faceHeight,
0, 0, faceWidth / tw, faceHeight / th, Vector4.One);
0, 0, faceWidth / tw, faceHeight / th, Tint);
}
}
}
if (Label is { Length: > 0 } label && LabelFont is { } lf)
{
float tx = LabelAlign == LabelAlignment.Left
? LabelOffsetX
: (Width - lf.MeasureWidth(label)) * 0.5f; // centered (default)
float ty = (Height - lf.LineHeight) * 0.5f;
ctx.DrawStringDat(lf, label, tx, ty, LabelColor, Outline, OutlineColor);
// GF-11c: LabelBox null (every pre-existing button) reduces boxX/
// boxY to 0 and boxWidth/boxHeight to the button's own Width/
// Height — byte-identical to the prior unconditional math.
float boxX = LabelBox?.X ?? 0f;
float boxY = LabelBox?.Y ?? 0f;
float boxWidth = LabelBox?.Width ?? Width;
float boxHeight = LabelBox?.Height ?? Height;
// R2-2/R2-3 (Campaign CC gate round 1 Batch E) + R3-2 correction
// (re-test 2): when this button ALSO carries a coexisting
// ValueLabel (GF-4a's own-caption + separate value slot — the
// Profession attribute/health/stamina/mana credits buttons, the
// Skills credits button), boxWidth still narrows to stop before
// the value's authored rect for the (currently unused, since
// every known ValueBox button is Left-aligned) Center-tx
// formula and the explicit-newline clip rect below — see
// DrawBlockLabel's own doc for why this no longer gates
// WHETHER a single-line caption wraps or clips (R3-2: it never
// did in retail — live-DAT-measured, "Available Skill Credits"
// fits the button's own full 231px width with room to spare).
if (ValueBox is { X: var valueBoxX } && valueBoxX > boxX)
boxWidth = MathF.Min(boxWidth, valueBoxX - boxX);
DrawBlockLabel(ctx, label, lf, LabelColor, boxX, boxY, boxWidth, boxHeight, LabelAlign, LabelOffsetX);
}
if (ValueLabel is { Length: > 0 } value && ValueFont is { } vf)
{
float boxX = ValueBox?.X ?? 0f;
float boxY = ValueBox?.Y ?? 0f;
float boxWidth = ValueBox?.Width ?? Width;
float boxHeight = ValueBox?.Height ?? Height;
float valueWidth = vf.MeasureWidth(value);
// R4-1: Right mirrors CalcJustification's own far-edge formula
// (box's own right edge minus the measured text width, no
// decorative inset — the decomp's Right branch adds none either,
// and this box carries no threaded marginR of its own).
float vx = ValueAlign switch
{
LabelAlignment.Left => boxX + LabelOffsetX,
LabelAlignment.Right => boxX + boxWidth - valueWidth,
_ => boxX + (boxWidth - valueWidth) * 0.5f,
};
float vy = boxY + (boxHeight - vf.LineHeight) * 0.5f;
ctx.DrawStringDat(vf, value, vx, vy, ValueColor, Outline, OutlineColor);
}
uint dragSprite = _itemDragAcceptance switch
@ -412,10 +603,154 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
{
var (tex, _, _) = _resolve(dragSprite);
if (tex != 0)
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Tint);
}
}
/// <summary>
/// R2-2 (Campaign CC gate round 1 Batch E) + R3-1/R3-2 (re-test 2
/// correction): retail's <c>UIElement_Button</c> IS a
/// <c>UIElement_Text</c> (<c>struct UIElement_Button : UIElement_Text</c>,
/// <c>acclient.h</c>) — a caption that carries an authored newline
/// (already normalized to a real <c>'\n'</c> by
/// <see cref="Layout.DatWidgetFactory"/>'s shared
/// <c>ResolveAuthoredString</c>) lays out as multiple stacked lines. A
/// single line that already fits draws with byte-identical geometry to
/// the pre-Batch-E unconditional one-line math (same centered-block Y,
/// same tx formula).
/// <para>
/// Batch E ALSO auto-wrapped a paragraph that doesn't fit
/// <paramref name="boxWidth"/> via <see cref="UiText.WrapWords"/> — re-
/// derived at re-test 2 (R3-1 "Coordination"/R3-2 "Available Skill
/// Credits") as the wrong shape and REMOVED: live-DAT-probed, the
/// Coordination slider label (<c>0x100002ed</c>) authors <c>OneLine=
/// true</c> (dat property <c>0x20</c>) and the Skills credits button
/// (<c>0x100003f9</c>) authors <c>OneLine=false</c> yet BOTH render one
/// line in retail. Tracing <c>GlyphList::Recalculate
/// @0x00473800</c>'s per-glyph loop: the ENTIRE width-triggered break
/// decision (and, separately, the explicit-newline break) sits behind
/// one gate, <c>if (arg3 == 0)</c> where <c>arg3</c> is the SAME
/// <c>OneLine</c> boolean passed in from
/// <c>UIElement_Text::ResizeToPaper</c>/<c>InqSize</c> — i.e. a
/// caption's width is measured against its own FULL element rect (minus
/// margins), never against a sibling/child element's geometry; nothing
/// in the decomp confines a caption's wrap width to stop before another
/// element's rect. The 193px "Available Skill Credits" caption fits the
/// button's own full 231px width (live-DAT-measured) with room to
/// spare — it never needed to wrap at all. So: split ONLY on the
/// explicit <c>\n</c> (never invoke <see cref="UiText.WrapWords"/>) — a
/// strict superset of the pre-Batch-E single-line draw for every
/// caption that was already correct, and the exact shape "Attribute\n
/// Credits" (an authored break) still needs.
/// </para>
/// <para>
/// R3-2 deliberately does NOT clip a single (unwrapped) line to
/// <paramref name="boxWidth"/> either, even when the caller narrowed it
/// via a coexisting <see cref="ValueBox"/> — clipping would cut the
/// caption's own tail off mid-word, which contradicts "retail is ONE
/// line" just as much as wrapping does (a viewer would call that
/// truncated, not "one line"). The 193px-in-231px Skills-credits
/// geometry means the caption's rendered span (x≈3 to x≈196) does
/// overlap the value's own rect (x=116 to x=150, live-DAT-measured) in
/// principle — Batch E's own diagnosis of the ORIGINAL R2-2/R2-3
/// "24dits"/"Credit0Credits" reports. That overlap is NOT re-solved
/// here: this fix only removes the false wrap this specific finding
/// (R3-2) reported, and inventing an unevidenced clip boundary to
/// pre-empt a DIFFERENT, not-currently-reported symptom would be
/// exactly the guessing this project's workflow forbids. Flagged in
/// the findings doc for the user's own re-check once the wrap is gone.
/// </para>
/// </summary>
private void DrawBlockLabel(
UiRenderContext ctx,
string text,
UiDatFont font,
Vector4 color,
float boxX,
float boxY,
float boxWidth,
float boxHeight,
LabelAlignment align,
float leftOffset)
{
IReadOnlyList<(string Text, float X, float Y)> lines = WrapBlockLines(
text, font.MeasureWidth, font.LineHeight,
boxX, boxY, boxWidth, boxHeight, align, leftOffset);
// A multi-line result (an authored '\n') clips to its own box — the
// button's normal draw has no ambient clip, and an oversized
// wrapped caption (e.g. the Skills credits button's own tight 28px
// height) should be cut off at the box edge rather than spill into
// whatever sits below the button, matching every other clipped
// Type-12 text box in this codebase (UiText.DrawText's own
// PushClip). Single-line captions — the overwhelming majority,
// and (post-R3-2) EVERY caption with no authored newline — never
// pay this cost; see this method's own doc for why a single line
// is deliberately left unclipped even when boxWidth was narrowed.
bool clip = lines.Count > 1;
if (clip)
ctx.PushClip(boxX, boxY, boxWidth, boxHeight);
try
{
foreach ((string line, float tx, float ty) in lines)
ctx.DrawStringDat(font, line, tx, ty, color, Outline, OutlineColor);
}
finally
{
if (clip)
ctx.PopClip();
}
}
/// <summary>
/// Pure geometry half of <see cref="DrawBlockLabel"/> — split ONLY on an
/// authored explicit <c>'\n'</c>, then block-centered vertically within
/// <paramref name="boxHeight"/>. Pulled out as a static/pure method
/// (same shape as <see cref="UiText.ContentOffsetX"/>) so the geometry
/// is unit-testable without a font atlas or draw context —
/// <paramref name="measureWidth"/> takes the place of
/// <see cref="UiDatFont.MeasureWidth(string)"/>.
/// <para>
/// R3-1/R3-2 (re-test 2): deliberately does NOT width-wrap a paragraph
/// that overflows <paramref name="boxWidth"/> — see
/// <see cref="DrawBlockLabel"/>'s own doc for the decomp citation
/// (<c>GlyphList::Recalculate</c>'s width-triggered break sits behind
/// the SAME <c>OneLine</c> gate as the explicit-newline break, and
/// retail never confines a caption's wrap width to a sibling element's
/// rect). A paragraph that overflows still draws as one line, unclipped
/// by width — matching every plain (no authored <c>\n</c>) button
/// caption in retail, which is never observed to wrap.
/// </para>
/// </summary>
internal static IReadOnlyList<(string Text, float X, float Y)> WrapBlockLines(
string text,
Func<string, float> measureWidth,
float lineHeight,
float boxX,
float boxY,
float boxWidth,
float boxHeight,
LabelAlignment align,
float leftOffset)
{
string[] lines = text.Split('\n');
float totalHeight = lines.Length * lineHeight;
float startY = boxY + (boxHeight - totalHeight) * 0.5f;
var result = new List<(string, float, float)>(lines.Length);
for (int i = 0; i < lines.Length; i++)
{
string line = lines[i];
float tx = align == LabelAlignment.Left
? boxX + leftOffset
: boxX + (boxWidth - measureWidth(line)) * 0.5f;
float ty = startY + i * lineHeight;
result.Add((line, tx, ty));
}
return result;
}
private void DrawFace(UiRenderContext ctx, uint file, UiPixelRect rect)
{
if (file == 0 || rect.Width <= 0 || rect.Height <= 0)
@ -428,7 +763,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
// first reflowed by its own four-edge retail layout policy.
ctx.DrawSprite(texture, rect.X0, rect.Y0, rect.Width, rect.Height,
0f, 0f, (float)rect.Width / textureWidth, (float)rect.Height / textureHeight,
Vector4.One);
Tint);
}
private void AddAvailableStates(ElementInfo mediaInfo)
@ -551,6 +886,11 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
OnClick?.Invoke();
OnClickAt?.Invoke(e.Data1, e.Data2);
return OnClick is not null || OnClickAt is not null;
case UiEventType.DoubleClick:
if (OnDoubleClick is null) return false;
if (!Enabled) return true;
OnDoubleClick.Invoke();
return true;
case UiEventType.RightClick:
// S6 (2026-08-11 review): unlike Click (whose swallow-when-
// disabled is pre-existing, harmless-by-construction behavior
@ -626,13 +966,77 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
private void UpdateVisualState()
{
uint requested = UiButtonStateMachine.RequestedState(new UiButtonVisualInput(
uint requested = ComputeRequestedStateId();
if (_hasCustomSelectionPair)
{
// gmCGHeritagePage::Update @0x00483219-0x0048372D (and the
// mirrored template/sub-tab/gender call sites): retail sets
// this pair directly by SELECTION, not through the ordinary
// Normal/Highlight/rollover/pressed machine — these buttons
// never author rollover or pressed media for the pair, so
// there is nothing faithful to compute beyond selected-or-not.
ActiveState = RetailUiStateIds.StateName(requested);
}
else if (_availableStates.Contains(requested))
{
ActiveState = UiButtonStateMachine.StateName(requested);
}
// AP-222: apply the per-state label style off the REQUESTED id, not
// the (possibly art-gated) committed ActiveState — retail's own
// SetState(6) commits the state's PROPERTIES (including text color)
// unconditionally; only the SPRITE draw silently no-ops when a
// state has no media (this class's own #382 comment on
// TrySetRetailState documents the same distinction). The
// Appearance spins' current-part highlight is exactly this case:
// _availableStates never contains Highlight (their arrow face
// segments carry no Highlight art), so ActiveState stays "Normal"
// forever, but the spin's OWN label color must still swap.
ApplyPerStateLabelStyle(requested);
}
private uint ComputeRequestedStateId()
=> _hasCustomSelectionPair
? (_selected ? RetailUiStateIds.Selected : RetailUiStateIds.Unselected)
: UiButtonStateMachine.RequestedState(new UiButtonVisualInput(
Disabled: !Enabled,
Selected: _selected,
RolloverEnabled: RolloverEnabled,
Pressed: _pressed,
PointerOver: _pointerOver));
if (_availableStates.Contains(requested))
ActiveState = UiButtonStateMachine.StateName(requested);
/// <summary>
/// AP-222 / GF-11b (Campaign CC gate round 1 Batch B): optional per-
/// RETAIL-STATE label color/outline override, additive over the single
/// default <see cref="LabelColor"/>/<see cref="Outline"/> lifted once at
/// construction. Set by <see cref="Layout.DatWidgetFactory"/> ONLY when
/// the authored dat genuinely carries more than one distinct value
/// across this button's (or its lifted caption child's) own states —
/// e.g. the Appearance spins' Highlight-state gold brightening
/// (dat properties <c>0x1B</c>/<c>0x21</c>, live-DAT-measured
/// 218,167,85 -&gt; 255,221,131 plus outline off -&gt; on) or the Town
/// buttons' Normal-to-white caption swap (218,167,85 -&gt; 255,255,255).
/// A button with a single authored color (the overwhelming majority)
/// never calls this, so <see cref="LabelColor"/>/<see cref="Outline"/>
/// keep behaving exactly as before — including every existing external
/// post-construction assignment (e.g. <c>ChatWindowController</c>'s Send
/// caption, <c>PaperdollController</c>'s Slots label), none of which
/// author a second distinct per-state color.
/// </summary>
internal void SetPerStateLabelStyle(
IReadOnlyDictionary<uint, Vector4>? colors,
IReadOnlyDictionary<uint, bool>? outlines)
{
_stateLabelColors = colors;
_stateLabelOutlines = outlines;
ApplyPerStateLabelStyle(ComputeRequestedStateId());
}
private void ApplyPerStateLabelStyle(uint requestedStateId)
{
if (_stateLabelColors is { } colors && colors.TryGetValue(requestedStateId, out Vector4 color))
LabelColor = color;
if (_stateLabelOutlines is { } outlines && outlines.TryGetValue(requestedStateId, out bool outline))
Outline = outline;
}
}

View file

@ -57,6 +57,19 @@ public abstract class UiElement
/// <summary>Human-readable name for debugging / FindByName.</summary>
public string? Name { get; init; }
/// <summary>
/// GF-13 (Campaign CC gate round 1, Batch A): mirrors
/// <c>ElementInfo.Invisible</c> (dat property <c>0x3B</c>) — a PURE DATA
/// PASSTHROUGH set by <c>LayoutImporter.BuildWidget</c> at construction.
/// The shared importer does NOT act on this flag (1,083 elements author
/// it client-wide, docs/ISSUES.md #408); it exists only so a screen that
/// owns its own mounted subtree can honor it explicitly, the way
/// <c>CharacterCreationUiController</c> does for the chargen screen
/// (register AP-230). Reading this never changes <see cref="Visible"/> by
/// itself.
/// </summary>
public bool AuthoredInvisible { get; internal set; }
private readonly Dictionary<string, UiCursorMedia> _stateCursors = new();
/// <summary>Retail MediaDescCursor entries keyed by UIStateId.ToString(), or "" for DirectState.</summary>

View file

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.UI;
@ -32,6 +33,134 @@ public sealed class UiRoot : UiElement
/// <summary>Single owner for named retained-window lifecycle and raise policy.</summary>
public RetailWindowManager WindowManager { get; }
/// <summary>
/// Campaign LA gate round 2 (register AD-98): when set, the retained tree
/// is laid out in this fixed authored canvas (the char-select screen's
/// 800×600) and the whole tree — widgets, glyphs, art — is stretched to
/// the window as one unit, matching retail's present-time frame stretch
/// for fixed-canvas pre-world screens. Draw applies the scale at the
/// renderer's quad chokepoint; the mouse entry points apply the inverse,
/// so <see cref="MouseX"/>/<see cref="MouseY"/> and every hit test live
/// in canvas space. Null (the in-world default) is native 1:1.
///
/// <para>
/// Campaign CC slice CC4 review-fix round R1 (2026-08-15): this raw
/// setter remains public for tests that exercise the scale/mouse-
/// mapping math in isolation (<c>UiRootFixedCanvasTests</c>), but
/// PRODUCTION code must go through <see cref="DeclareFixedCanvas"/>/
/// <see cref="RevokeFixedCanvas"/> instead of writing this property
/// directly. Two fixed-canvas screens can be active at once
/// (character-management underneath character-creation) and a raw
/// write from either one is a last-writer-wins race with no owner —
/// the F1 fix's own <c>Close()</c> null wiped the OTHER screen's still-
/// active canvas out from under it (see AD-98).
/// </para>
/// </summary>
public Vector2? FixedCanvasSize { get; set; }
/// <summary>Screens currently declaring a fixed canvas, keyed by owner
/// (see <see cref="DeclareFixedCanvas"/>).</summary>
private readonly Dictionary<object, Vector2> _fixedCanvasDeclarations = new();
/// <summary>
/// Declares that <paramref name="owner"/> wants the retained tree laid
/// out in <paramref name="size"/> while it is active. This is the single
/// arbiter for <see cref="FixedCanvasSize"/>: multiple owners may declare
/// concurrently (character-management stays declared while character-
/// creation is also open on top of it), and the effective
/// <see cref="FixedCanvasSize"/> is the shared declaration set's value.
/// Every current declarer must agree on the size — a mismatched second
/// declaration throws rather than silently overwriting the first
/// (Campaign CC CC4 review-fix round R1, 2026-08-15; see
/// <c>docs/architecture/retail-divergence-register.md</c> AD-98). Pair
/// every call with <see cref="RevokeFixedCanvas"/> on the SAME owner at
/// deactivate/close/dispose.
/// </summary>
public void DeclareFixedCanvas(object owner, Vector2 size)
{
ArgumentNullException.ThrowIfNull(owner);
if (_fixedCanvasDeclarations.TryGetValue(owner, out Vector2 existing))
{
if (existing == size)
return; // idempotent re-declare (e.g. a re-ticked activation edge)
throw new InvalidOperationException(
$"UiRoot.DeclareFixedCanvas: owner {owner} re-declared a different " +
$"canvas ({existing} -> {size}) without revoking first.");
}
foreach (Vector2 declared in _fixedCanvasDeclarations.Values)
{
if (declared != size)
{
throw new InvalidOperationException(
$"UiRoot.DeclareFixedCanvas: owner {owner} declared {size} but " +
$"another active owner already declared {declared} — every " +
"concurrently-active fixed-canvas screen must author the SAME " +
"canvas size (see AD-98).");
}
}
_fixedCanvasDeclarations[owner] = size;
FixedCanvasSize = size;
}
/// <summary>Revokes <paramref name="owner"/>'s declaration from
/// <see cref="DeclareFixedCanvas"/>. <see cref="FixedCanvasSize"/>
/// becomes null only once EVERY declarer has revoked; while another
/// owner is still declared, it stays set to that shared value. A
/// revoke from an owner that never declared (or already revoked) is a
/// no-op, matching the idempotent shutdown paths (<c>Deactivate</c>
/// AND <c>Dispose</c> can both revoke the same owner).</summary>
public void RevokeFixedCanvas(object owner)
{
ArgumentNullException.ThrowIfNull(owner);
if (!_fixedCanvasDeclarations.Remove(owner))
return;
if (_fixedCanvasDeclarations.Count == 0)
{
FixedCanvasSize = null;
return;
}
foreach (Vector2 declared in _fixedCanvasDeclarations.Values)
{
FixedCanvasSize = declared;
break;
}
}
/// <summary>
/// The coordinate space the retained tree currently lays out in: the fixed
/// authored canvas while one is active, else the window itself. Anything
/// that positions against "the screen" (dialog centering, full-screen
/// scrims) must use THIS — the gate-round-2 exit dialog centered against
/// the 1920px window while the tree lived in the 800px canvas, landing far
/// right of the visible screen center.
/// </summary>
public Vector2 EffectiveCanvasSize =>
FixedCanvasSize is { X: > 0f, Y: > 0f } canvas
? canvas
: new Vector2(Width, Height);
/// <summary>Window→canvas stretch factor; One when no fixed canvas is set.</summary>
public Vector2 CanvasScale =>
FixedCanvasSize is { X: > 0f, Y: > 0f } canvas && Width > 0f && Height > 0f
? new Vector2(Width / canvas.X, Height / canvas.Y)
: Vector2.One;
private (int x, int y) MapWindowToCanvas(int x, int y)
{
// Truncate, not round (batch review F6): rounding maps the window's
// last column/row one past the canvas's last valid coordinate
// (1919/2.4 → 800, past 799), creating a 1px dead band at the far
// right/bottom edge. Truncation maps 0..1919 onto 0..799 exactly.
Vector2 scale = CanvasScale;
return scale == Vector2.One
? (x, y)
: ((int)(x / scale.X), (int)(y / scale.Y));
}
// ── Device-level state ───────────────────────────────────────────────
public int MouseX { get; private set; }
public int MouseY { get; private set; }
@ -370,6 +499,21 @@ public sealed class UiRoot : UiElement
}
public void Draw(UiRenderContext ctx)
{
// AD-98 fixed-canvas stretch: scope the renderer's canvas scale to
// exactly this tree's draws (world-space HUD stays native).
ctx.TextRenderer.CanvasScale = CanvasScale;
try
{
DrawCore(ctx);
}
finally
{
ctx.TextRenderer.CanvasScale = Vector2.One;
}
}
private void DrawCore(UiRenderContext ctx)
{
// Render children (panels) sorted by z-order — modal last so it
// sits on top.
@ -401,6 +545,7 @@ public sealed class UiRoot : UiElement
public void OnMouseMove(int x, int y)
{
(x, y) = MapWindowToCanvas(x, y);
int dx = x - MouseX;
int dy = y - MouseY;
MouseX = x;
@ -552,6 +697,7 @@ public sealed class UiRoot : UiElement
public void OnMouseDown(UiMouseButton btn, int x, int y, uint flags = 0)
{
(x, y) = MapWindowToCanvas(x, y);
MouseX = x; MouseY = y;
UpdateButtonFlag(btn, down: true);
_pressX = x; _pressY = y;
@ -707,6 +853,7 @@ public sealed class UiRoot : UiElement
public void OnMouseUp(UiMouseButton btn, int x, int y, uint flags = 0)
{
(x, y) = MapWindowToCanvas(x, y);
MouseX = x; MouseY = y;
UpdateButtonFlag(btn, down: false);

View file

@ -249,6 +249,12 @@ public sealed class UiScrollbar : UiElement
return;
}
if (ScalarChanged is not null)
{
DrawVerticalScalar(ctx, resolve);
return;
}
if (Model is not { } m) return;
// Track background — TILED vertically (retail DrawMode=Normal). The native track
@ -280,11 +286,60 @@ public sealed class UiScrollbar : UiElement
}
else
{
DrawTiled(ctx, resolve, ThumbSprite, 0f, ty, Width, th);
// R4-2 (Campaign CC gate round 1 re-test 3): the single-
// sprite thumb shape (no top/bottom caps — see this method's
// own doc, the R3-4/R3-7 fallback family: Skills listbox
// 0x100003f8, Summary overview 0x10000401, Summary how-to
// 0x100002e7) is a small fixed "diamond" marker graphic, NOT
// a stretchy bar — DrawTiled's UV-repeat was drawing it
// MULTIPLE times to fill the track-proportional thumb rect
// (~9 repeats on Summary's overview bar, ~2 on Skills, per
// the live capture). DrawThumbMarker draws exactly ONE
// instance at its own native size.
DrawThumbMarker(ctx, resolve, ThumbSprite, 0f, ty, Width, th, vertical: true);
}
}
}
/// <summary>
/// R4-2 (Campaign CC gate round 1 re-test 3): draws ONE instance of a
/// single-sprite scrollbar thumb at its own native size, centered
/// within the computed thumb rect (<see cref="ThumbRect"/>'s own
/// decomp-cited <c>UIElement_Scrollbar::UpdateLayout @0x4710d0</c>
/// track-proportional geometry stays unchanged — this only changes HOW
/// the sprite fills that rect). Neither <see cref="DrawTiled"/> (UV-
/// repeat — draws the small marker graphic several times to fill a
/// large proportional thumb rect, R4-2's own "tiled diamonds" report)
/// nor a naive 1:1 stretch across the full computed rect (would distort
/// a small marker into an elongated bar) is correct for this shape —
/// <paramref name="vertical"/> selects which
/// axis is being filled/centered: a vertical scrollbar's thumb rect
/// varies in height (X/Width stay the bar's own full width, matching
/// every other draw call in this class), a horizontal one varies in
/// width (Y/Height stay the bar's own full height).
/// </summary>
private void DrawThumbMarker(
UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
uint id, float rectX, float rectY, float rectW, float rectH, bool vertical)
{
if (id == 0 || rectW <= 0f || rectH <= 0f) return;
var (tex, nativeW, nativeH) = resolve(id);
if (tex == 0 || nativeW == 0 || nativeH == 0) return;
if (vertical)
{
float drawH = MathF.Min(nativeH, rectH);
float y = rectY + (rectH - drawH) * 0.5f;
ctx.DrawSprite(tex, rectX, y, rectW, drawH, 0f, 0f, rectW / nativeW, drawH / nativeH, Vector4.One);
}
else
{
float drawW = MathF.Min(nativeW, rectW);
float x = rectX + (rectW - drawW) * 0.5f;
ctx.DrawSprite(tex, x, rectY, drawW, rectH, 0f, 0f, drawW / nativeW, rectH / nativeH, Vector4.One);
}
}
private void DrawHorizontalModel(
UiRenderContext ctx,
Func<uint, (uint tex, int w, int h)> resolve,
@ -309,10 +364,35 @@ public sealed class UiScrollbar : UiElement
}
else
{
DrawTiled(ctx, resolve, ThumbSprite, tx, 0f, tw, Height);
// R4-2: horizontal counterpart of the vertical fallback above.
DrawThumbMarker(ctx, resolve, ThumbSprite, tx, 0f, tw, Height, vertical: false);
}
}
/// <summary>
/// Fix round F11 (Campaign CC CC6b-MOUNT review): the mirror-image
/// counterpart of the horizontal scalar draw block above, for scalar-mode
/// bars authored VERTICAL (taller than wide) — retail's chargen shade
/// scrollbar (<c>0x10000321</c>) is one, measured against the installed
/// EoR dat (<c>Width=33 Height=85</c>). Retail's own
/// <c>UIElement_Scrollbar</c> is one class handling both a model-driven
/// list scroll and a scalar-value slider on EITHER axis; this class only
/// had the horizontal half of the scalar shape before this fix, so a
/// vertically-authored scalar bar (like the shade control) drew nothing
/// scalar-specific and fell through to the model-mode branch below,
/// which requires a <see cref="UiScrollable"/> <see cref="Model"/> a
/// scalar-mode bar never has.
/// </summary>
private void DrawVerticalScalar(
UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve)
{
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
float thumbHeight = ScalarThumbExtent(resolve, Height);
float travel = MathF.Max(0f, Height - thumbHeight);
float y = travel * ScalarPosition;
DrawSprite(ctx, resolve, ThumbSprite, 0f, y, Width, thumbHeight);
}
/// <summary>Draw a sprite stretched 1:1 to the dest rect.</summary>
private void DrawSprite(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
uint id, float x, float y, float w, float h)
@ -412,8 +492,17 @@ public sealed class UiScrollbar : UiElement
if (e.Type == UiEventType.MouseMove)
_hoveredButton = ButtonAt(e.Data1, e.Data2);
if (Horizontal && ScalarChanged is not null)
return OnScalarEvent(e);
// Fix round F11: retail's chargen shade scrollbar (0x10000321) is
// authored VERTICAL (measured against the installed dat), but a
// scalar-mode bar (ScalarChanged set, no Model) has always been
// possible on either axis in retail's own UIElement_Scrollbar.
// Gating this dispatch on Horizontal silently dropped every mouse
// event for a vertical scalar bar — it fell through the Horizontal
// Model branch below too, then hit "Model is not {} m => return
// false" since a scalar bar has no Model, so NOTHING ever routed to
// ScalarChanged in production for this orientation.
if (ScalarChanged is not null)
return Horizontal ? OnScalarEvent(e) : OnVerticalScalarEvent(e);
if (Horizontal && Model is not null)
return OnHorizontalModelEvent(e);
@ -590,14 +679,77 @@ public sealed class UiScrollbar : UiElement
return false;
}
private float ScalarThumbWidth(Func<uint, (uint tex, int w, int h)>? resolve)
/// <summary>F11: the vertical mirror of <see cref="OnScalarEvent"/> —
/// same click-thumb-to-drag / click-track-to-jump shape, along Y/Height
/// instead of X/Width. Reuses <see cref="_dragOffsetY"/> (otherwise only
/// touched by the vertical MODEL-mode drag, mutually exclusive with
/// scalar mode on one instance) rather than adding a third offset field.
/// </summary>
private bool OnVerticalScalarEvent(in UiEvent e)
{
switch (e.Type)
{
case UiEventType.MouseDown:
{
float thumbHeight = ScalarThumbExtent(SpriteResolve, Height);
float travel = MathF.Max(1f, Height - thumbHeight);
float thumbY = travel * ScalarPosition;
float y = e.Data2;
// OP5 re-check R2 (mirrored from OnScalarEvent): latch
// before the jump so the jump's own tick defers its flush
// to MouseUp's DragCompleted.
_draggingThumb = true;
if (y >= thumbY && y <= thumbY + thumbHeight)
{
_dragOffsetY = y - thumbY;
}
else
{
_dragOffsetY = thumbHeight * 0.5f;
ChangeScalarPosition((y - _dragOffsetY) / travel);
}
return true;
}
case UiEventType.MouseMove when _draggingThumb:
{
float thumbHeight = ScalarThumbExtent(SpriteResolve, Height);
float travel = MathF.Max(1f, Height - thumbHeight);
ChangeScalarPosition(((float)e.Data2 - _dragOffsetY) / travel);
return true;
}
case UiEventType.MouseUp:
{
bool wasDragging = _draggingThumb;
_draggingThumb = false;
_pressedButton = EndButton.None;
if (wasDragging) DragCompleted?.Invoke();
return true;
}
}
return false;
}
private float ScalarThumbWidth(Func<uint, (uint tex, int w, int h)>? resolve) =>
ScalarThumbExtent(resolve, Width);
/// <summary>F11: generalized over <see cref="ScalarThumbWidth"/> so
/// <see cref="DrawVerticalScalar"/> can size the thumb along the
/// authored axis (native sprite width for a horizontal bar, native
/// sprite height for a vertical one) instead of assuming horizontal.
/// </summary>
private float ScalarThumbExtent(
Func<uint, (uint tex, int w, int h)>? resolve, float axisLength)
{
if (resolve is not null && ThumbSprite != 0)
{
var (_, width, _) = resolve(ThumbSprite);
if (width > 0) return MathF.Min(width, Width);
var (_, width, height) = resolve(ThumbSprite);
int native = Horizontal ? width : height;
if (native > 0) return MathF.Min(native, axisLength);
}
return MathF.Min(16f, Width);
return MathF.Min(16f, axisLength);
}
private void ChangeScalarPosition(float position)

View file

@ -146,6 +146,27 @@ public sealed class UiText : UiElement, IUiDatStateful
/// </summary>
public float Padding { get; set; }
/// <summary>
/// Campaign CC gate round 1 Batch E (R2-1): the four independent retail
/// text-inset margins (dat properties <c>0x23</c>/<c>0x24</c>/<c>0x25</c>/
/// <c>0x26</c> — <see cref="Layout.ElementInfo.MarginLeft"/>'s own doc
/// comment has the full decomp citation). Additive with
/// <see cref="Padding"/> (every existing controller that sets
/// <see cref="Padding"/> explicitly keeps behaving identically, since
/// these four default to 0 unless <see cref="Layout.DatWidgetFactory"/>
/// seeds them from the DAT). Applied ONLY to the scrollable multi-line
/// path (<see cref="OneLine"/> == false) — the chargen description boxes
/// that regressed in Batch C are all multi-line, and every authored
/// nonzero-margin box measured against the installed DAT so far is also
/// multi-line. The static Centered/RightAligned/OneLine single-line
/// paths are unchanged (still bare <see cref="Padding"/>) to keep this
/// fix's blast radius to the mechanism that actually regressed.
/// </summary>
public float MarginLeft { get; set; }
public float MarginRight { get; set; }
public float MarginTop { get; set; }
public float MarginBottom { get; set; }
/// <summary>Retail property 0x20. Independent of horizontal/vertical
/// justification; false permits the normal multi-line layout path.</summary>
public bool OneLine { get; set; }
@ -555,7 +576,10 @@ public sealed class UiText : UiElement, IUiDatStateful
if (lines.Count == 0) return;
float lh = _lastLineHeight;
float top = Padding, bottom = Height - Padding;
// R2-1: the multi-line viewport insets by BOTH Padding (the pre-
// existing uniform inset controllers already set) AND the four
// retail-authored margins (additive — see MarginTop's own doc).
float top = Padding + MarginTop, bottom = Height - Padding - MarginBottom;
float innerH = bottom - top;
float contentH = lines.Count * lh;
@ -731,11 +755,37 @@ public sealed class UiText : UiElement, IUiDatStateful
float width = datFont is not null
? datFont.MeasureWidth(text)
: bitmapFont?.MeasureWidth(text) ?? 0f;
if (Centered)
return Math.Max(Padding, (Width - width) * 0.5f);
if (RightAligned)
return Math.Max(Padding, Width - Padding - width);
return Padding;
return ContentOffsetX(Width, Padding, MarginLeft, MarginRight, width, Centered, RightAligned);
}
/// <summary>
/// R2-1 (Campaign CC gate round 1 Batch E): pure per-line horizontal
/// placement for the MULTI-LINE (scrollable) path — the static
/// single-line Centered/RightAligned/OneLine branches in
/// <see cref="DrawClippedText"/> have their own inline math and are
/// deliberately left on bare <see cref="Padding"/> (see
/// <see cref="MarginLeft"/>'s own doc comment). Here, both
/// <see cref="Padding"/> and the four retail margins inset the content
/// box a line lays out within. Pure/static so it is unit-testable
/// without a font or draw context — the same shape as
/// <see cref="VOffset"/>/<see cref="ContentBaseY"/> above.
/// </summary>
public static float ContentOffsetX(
float elementWidth,
float padding,
float marginLeft,
float marginRight,
float lineWidth,
bool centered,
bool rightAligned)
{
float contentLeft = padding + marginLeft;
float contentRight = elementWidth - padding - marginRight;
if (centered)
return Math.Max(contentLeft, contentLeft + (contentRight - contentLeft - lineWidth) * 0.5f);
if (rightAligned)
return Math.Max(contentLeft, contentRight - lineWidth);
return contentLeft;
}
public override bool OnEvent(in UiEvent e)

View file

@ -12,6 +12,7 @@
<ItemGroup>
<InternalsVisibleTo Include="AcDream.Bake.Tests" />
<InternalsVisibleTo Include="AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder" />
</ItemGroup>
<ItemGroup>
@ -24,6 +25,7 @@
<ItemGroup>
<ProjectReference Include="..\AcDream.Content\AcDream.Content.csproj" />
<ProjectReference Include="..\AcDream.Platform\AcDream.Platform.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,133 @@
using System.Globalization;
namespace AcDream.Bake;
internal sealed record BakeCommandLineOptions(
string DatDirectory,
string OutputPath,
HashSet<uint>? IdFilter,
HashSet<uint>? LandblockFilter,
int Threads,
bool ProgressJson);
internal static class BakeCommandLine
{
internal const string Usage =
"usage: acdream-bake --dat-dir <path> [--out <file>] "
+ "[--ids 0xId,0xId,...] [--landblocks 0xId,...] "
+ "[--threads <n>] [--progress-json]\n"
+ " acdream-bake --help";
public static bool IsHelpRequest(IReadOnlyList<string> args)
{
ArgumentNullException.ThrowIfNull(args);
return args.Count == 1
&& args[0] is "--help" or "-h";
}
public static bool TryParse(
IReadOnlyList<string> args,
TextWriter error,
out BakeCommandLineOptions? options)
{
ArgumentNullException.ThrowIfNull(args);
ArgumentNullException.ThrowIfNull(error);
string? datDirectory = null;
string? outputPath = null;
HashSet<uint>? idFilter = null;
HashSet<uint>? landblockFilter = null;
int threads = Environment.ProcessorCount;
bool progressJson = false;
for (int i = 0; i < args.Count; i++)
{
switch (args[i])
{
case "--dat-dir":
datDirectory = Value(args, ref i);
break;
case "--out":
outputPath = Value(args, ref i);
break;
case "--ids":
idFilter = ParseHexList(Value(args, ref i), error);
break;
case "--landblocks":
landblockFilter = ParseHexList(Value(args, ref i), error);
break;
case "--threads":
if (int.TryParse(
Value(args, ref i),
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out int parsedThreads)
&& parsedThreads > 0)
{
threads = parsedThreads;
}
break;
case "--progress-json":
progressJson = true;
break;
default:
error.WriteLine($"unrecognized argument: {args[i]}");
options = null;
return false;
}
}
if (string.IsNullOrWhiteSpace(datDirectory))
{
error.WriteLine(Usage);
options = null;
return false;
}
outputPath ??= Path.Combine(datDirectory, "acdream.pak");
options = new BakeCommandLineOptions(
datDirectory,
outputPath,
idFilter,
landblockFilter,
threads,
progressJson);
return true;
}
private static string? Value(IReadOnlyList<string> args, ref int index) =>
index + 1 < args.Count ? args[++index] : null;
private static HashSet<uint> ParseHexList(string? raw, TextWriter error)
{
var result = new HashSet<uint>();
if (string.IsNullOrWhiteSpace(raw))
{
return result;
}
foreach (string token in raw.Split(
',',
StringSplitOptions.RemoveEmptyEntries
| StringSplitOptions.TrimEntries))
{
string hex = token.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
? token[2..]
: token;
if (uint.TryParse(
hex,
NumberStyles.HexNumber,
CultureInfo.InvariantCulture,
out uint value))
{
result.Add(value);
}
else
{
error.WriteLine($"warning: could not parse id '{token}' - skipped");
}
}
return result;
}
}

View file

@ -9,11 +9,28 @@ namespace AcDream.Bake;
/// </summary>
public static class BakeOutputTransaction
{
internal const string StagingMarker = ".acdream-bake.";
public static TResult WriteValidateAndPublish<TResult>(
string destinationPath,
Func<string, TResult> writeTemporary,
Action<string, TResult> validateTemporary,
CancellationToken cancellationToken = default)
=> WriteValidateAndPublish(
destinationPath,
writeTemporary,
validateTemporary,
beforePublicationLock: null,
beforePromotion: null,
cancellationToken);
internal static TResult WriteValidateAndPublish<TResult>(
string destinationPath,
Func<string, TResult> writeTemporary,
Action<string, TResult> validateTemporary,
Action? beforePublicationLock,
Action? beforePromotion,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath);
ArgumentNullException.ThrowIfNull(writeTemporary);
@ -25,9 +42,7 @@ public static class BakeOutputTransaction
throw new InvalidOperationException("destination has no parent directory");
Directory.CreateDirectory(directory);
string temporaryPath = Path.Combine(
directory,
$".{Path.GetFileName(fullDestination)}.{Guid.NewGuid():N}.tmp");
string temporaryPath = CreateStagingPath(fullDestination, Guid.NewGuid());
try
{
@ -36,6 +51,14 @@ public static class BakeOutputTransaction
cancellationToken.ThrowIfCancellationRequested();
validateTemporary(temporaryPath, result);
cancellationToken.ThrowIfCancellationRequested();
beforePublicationLock?.Invoke();
using IDisposable? publication =
BakePublicationGuard.AcquireIfRequested(
fullDestination,
cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
beforePromotion?.Invoke();
cancellationToken.ThrowIfCancellationRequested();
// Same-volume MoveFileEx/rename is the publication primitive.
// File.Replace additionally performs destination metadata/backup
@ -60,4 +83,21 @@ public static class BakeOutputTransaction
}
}
}
/// <summary>
/// Exact adjacent staging-name contract shared, by documentation and
/// conformance tests, with Launcher.Core. Keeping this tiny contract in
/// each BCL-facing assembly avoids an otherwise inverted project edge.
/// </summary>
internal static string CreateStagingPath(string destinationPath, Guid transactionId)
{
string fullDestination = Path.GetFullPath(destinationPath);
string directory = Path.GetDirectoryName(fullDestination)
?? throw new InvalidOperationException(
"destination has no parent directory");
return Path.Combine(
directory,
$".{Path.GetFileName(fullDestination)}{StagingMarker}"
+ $"{transactionId:N}.tmp");
}
}

View file

@ -0,0 +1,101 @@
using System.Text.Json;
namespace AcDream.Bake;
public interface IBakeProgressSink
{
void Started(uint bakeToolVersion, string outputPath);
void Progress(
string phase,
long completed,
long total,
int failures,
double elapsedSeconds,
double etaSeconds,
long privateBytes,
long managedBytes);
void Completed(uint bakeToolVersion, long outputBytes, int failures);
void Error(string message);
}
/// <summary>
/// Version-1 JSON-lines machine channel enabled only by
/// <c>--progress-json</c>. Ordinary human console lines remain unchanged and
/// share stdout; consumers identify these records by shape instead of
/// scraping human prose.
/// </summary>
public sealed class BakeProgressJsonWriter(TextWriter output) : IBakeProgressSink
{
public const int CurrentVersion = 1;
private readonly TextWriter _output = output
?? throw new ArgumentNullException(nameof(output));
private readonly object _gate = new();
public void Started(uint bakeToolVersion, string outputPath) =>
Write(new
{
v = CurrentVersion,
e = "started",
t = DateTimeOffset.UtcNow,
bakeToolVersion,
outputPath,
});
public void Progress(
string phase,
long completed,
long total,
int failures,
double elapsedSeconds,
double etaSeconds,
long privateBytes,
long managedBytes) =>
Write(new
{
v = CurrentVersion,
e = "progress",
t = DateTimeOffset.UtcNow,
phase,
completed,
total,
failures,
elapsedSeconds,
etaSeconds,
privateBytes,
managedBytes,
});
public void Completed(uint bakeToolVersion, long outputBytes, int failures) =>
Write(new
{
v = CurrentVersion,
e = "completed",
t = DateTimeOffset.UtcNow,
bakeToolVersion,
outputBytes,
failures,
});
public void Error(string message) =>
Write(new
{
v = CurrentVersion,
e = "error",
t = DateTimeOffset.UtcNow,
message,
});
private void Write<T>(T value)
{
string line = JsonSerializer.Serialize(value);
lock (_gate)
{
_output.WriteLine(line);
_output.Flush();
}
}
}

View file

@ -0,0 +1,34 @@
namespace AcDream.Bake;
internal static class BakeProgressReporter
{
public static void Write(
TextWriter humanOutput,
IBakeProgressSink? machineOutput,
string phase,
long completed,
int total,
int failures,
TimeSpan elapsed,
double etaSeconds,
long privateBytes,
long managedBytes)
{
ArgumentNullException.ThrowIfNull(humanOutput);
humanOutput.WriteLine(
$"[{elapsed:hh\\:mm\\:ss}] extracted {completed:N0}/{total:N0}, "
+ $"failures={failures:N0}, elapsed={elapsed.TotalSeconds:F0}s, "
+ $"ETA={etaSeconds:F0}s, "
+ $"private={privateBytes / 1024.0 / 1024.0:F0}MB, "
+ $"managed={managedBytes / 1024.0 / 1024.0:F0}MB");
machineOutput?.Progress(
phase,
completed,
total,
failures,
elapsed.TotalSeconds,
etaSeconds,
privateBytes,
managedBytes);
}
}

View file

@ -0,0 +1,80 @@
using AcDream.Platform;
namespace AcDream.Bake;
/// <summary>
/// Optional launcher authorization checked immediately before atomic
/// publication. Standalone Bake runs have no nonce environment variable and
/// retain the original unguarded behavior.
/// </summary>
internal static class BakePublicationGuard
{
private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(50);
internal static IDisposable? AcquireIfRequested(
string outputPath,
CancellationToken cancellationToken)
{
string? nonce = Environment.GetEnvironmentVariable(
BakePublicationGuardPaths.NonceEnvironmentVariable);
if (nonce is null)
{
return null;
}
if (!BakePublicationGuardPaths.IsValidNonce(nonce))
{
throw new InvalidOperationException(
"The launcher bake publication nonce is invalid.");
}
string lockPath = BakePublicationGuardPaths.GetPublishLockPath(
outputPath);
Directory.CreateDirectory(
Path.GetDirectoryName(lockPath)
?? throw new InvalidOperationException(
"The bake publication lock has no parent directory."));
FileStream? lease = null;
while (lease is null)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
lease = new FileStream(
lockPath,
FileMode.OpenOrCreate,
FileAccess.ReadWrite,
FileShare.None,
bufferSize: 1,
options: FileOptions.None);
}
catch (IOException)
{
cancellationToken.WaitHandle.WaitOne(RetryDelay);
}
}
try
{
string authorizationPath =
BakePublicationGuardPaths.GetAuthorizationPath(outputPath);
string authorized = File.Exists(authorizationPath)
? File.ReadAllText(authorizationPath)
: string.Empty;
if (!string.Equals(authorized, nonce, StringComparison.Ordinal))
{
throw new InvalidOperationException(
"This bake process is no longer authorized to publish its output.");
}
return lease;
}
catch
{
lease.Dispose();
throw;
}
}
}

View file

@ -22,6 +22,7 @@ public sealed record BakeOptions
public HashSet<uint>? LandblockFilter { get; init; }
public int Threads { get; init; } = System.Environment.ProcessorCount;
public CancellationToken CancellationToken { get; init; }
public IBakeProgressSink? Progress { get; init; }
}
/// <summary>Compact result used by the full-scale gate and deterministic tests.</summary>
@ -89,6 +90,7 @@ public static class BakeRunner
throw new ArgumentOutOfRangeException(nameof(options), "thread count must be positive");
options.CancellationToken.ThrowIfCancellationRequested();
options.Progress?.Started(PakFormat.CurrentBakeToolVersion, options.OutPath);
var totalStopwatch = Stopwatch.StartNew();
var report = BakeOutputTransaction.WriteValidateAndPublish(
options.OutPath,
@ -112,6 +114,10 @@ public static class BakeRunner
};
PrintSummary(report, options.OutPath);
options.Progress?.Completed(
report.Header.BakeToolVersion,
report.OutputBytes,
report.Failures);
return report;
}
@ -273,6 +279,8 @@ public static class BakeRunner
failures.Count,
stopwatch.Elapsed,
lastProgressReport,
options.Progress,
"mesh",
batchStart + BatchSize >= ordinaryWork.Count &&
envCatalog.UniqueGeometryCount == 0);
}
@ -372,6 +380,8 @@ public static class BakeRunner
failures.Count,
stopwatch.Elapsed,
lastProgressReport,
options.Progress,
"mesh",
batchStart + BatchSize >= envCatalog.Groups.Count);
}
@ -571,6 +581,8 @@ public static class BakeRunner
failures.Count,
collisionStopwatch.Elapsed,
lastProgressReport,
options.Progress,
"collision",
final: false);
}
@ -757,6 +769,8 @@ public static class BakeRunner
failures.Count,
collisionStopwatch.Elapsed,
lastProgressReport,
options.Progress,
"collision",
final: false);
}
}
@ -769,6 +783,8 @@ public static class BakeRunner
failures.Count,
collisionStopwatch.Elapsed,
lastProgressReport,
options.Progress,
"collision",
final: true);
writer.Finish();
@ -910,6 +926,8 @@ public static class BakeRunner
int failures,
TimeSpan elapsed,
Stopwatch lastProgressReport,
IBakeProgressSink? progress,
string phase,
bool final)
{
if (!final && lastProgressReport.Elapsed.TotalSeconds < 5)
@ -920,11 +938,18 @@ public static class BakeRunner
using var process = Process.GetCurrentProcess();
process.Refresh();
long managedHeap = GC.GetGCMemoryInfo().HeapSizeBytes;
Console.WriteLine(
$"[{elapsed:hh\\:mm\\:ss}] extracted {done:N0}/{total:N0}, " +
$"failures={failures:N0}, elapsed={elapsed.TotalSeconds:F0}s, " +
$"ETA={etaSeconds:F0}s, private={process.PrivateMemorySize64 / 1024.0 / 1024.0:F0}MB, " +
$"managed={managedHeap / 1024.0 / 1024.0:F0}MB");
long privateBytes = process.PrivateMemorySize64;
BakeProgressReporter.Write(
Console.Out,
progress,
phase,
done,
total,
failures,
elapsed,
etaSeconds,
privateBytes,
managedHeap);
lastProgressReport.Restart();
}

Some files were not shown because too many files have changed in this diff Show more