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
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:
commit
d3755eb231
419 changed files with 88143 additions and 937 deletions
109
.github/workflows/headless-portability.yml
vendored
109
.github/workflows/headless-portability.yml
vendored
|
|
@ -5,6 +5,10 @@ on:
|
||||||
paths:
|
paths:
|
||||||
- ".github/workflows/headless-portability.yml"
|
- ".github/workflows/headless-portability.yml"
|
||||||
- "AcDream.slnx"
|
- "AcDream.slnx"
|
||||||
|
- "src/AcDream.Platform/**"
|
||||||
|
- "src/AcDream.Launcher.Core/**"
|
||||||
|
- "src/AcDream.Launcher/**"
|
||||||
|
- "src/AcDream.Bake/**"
|
||||||
- "src/AcDream.Core/**"
|
- "src/AcDream.Core/**"
|
||||||
- "src/AcDream.Core.Net/**"
|
- "src/AcDream.Core.Net/**"
|
||||||
- "src/AcDream.Content/**"
|
- "src/AcDream.Content/**"
|
||||||
|
|
@ -13,6 +17,10 @@ on:
|
||||||
- "src/AcDream.Headless/**"
|
- "src/AcDream.Headless/**"
|
||||||
- "src/AcDream.App/**"
|
- "src/AcDream.App/**"
|
||||||
- "src/AcDream.UI.Abstractions/**"
|
- "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.Tests/**"
|
||||||
- "tests/AcDream.Core.Net.Tests/**"
|
- "tests/AcDream.Core.Net.Tests/**"
|
||||||
- "tests/AcDream.Content.Tests/**"
|
- "tests/AcDream.Content.Tests/**"
|
||||||
|
|
@ -20,12 +28,17 @@ on:
|
||||||
- "tests/AcDream.Headless.Tests/**"
|
- "tests/AcDream.Headless.Tests/**"
|
||||||
- "tests/AcDream.App.Tests/**"
|
- "tests/AcDream.App.Tests/**"
|
||||||
- "tests/AcDream.UI.Abstractions.Tests/**"
|
- "tests/AcDream.UI.Abstractions.Tests/**"
|
||||||
|
- "tests/Fixtures/campaign-la/**"
|
||||||
- "tools/ShaderCompiler/**"
|
- "tools/ShaderCompiler/**"
|
||||||
- "tools/compile-shaders.ps1"
|
- "tools/compile-shaders.ps1"
|
||||||
push:
|
push:
|
||||||
paths:
|
paths:
|
||||||
- ".github/workflows/headless-portability.yml"
|
- ".github/workflows/headless-portability.yml"
|
||||||
- "AcDream.slnx"
|
- "AcDream.slnx"
|
||||||
|
- "src/AcDream.Platform/**"
|
||||||
|
- "src/AcDream.Launcher.Core/**"
|
||||||
|
- "src/AcDream.Launcher/**"
|
||||||
|
- "src/AcDream.Bake/**"
|
||||||
- "src/AcDream.Core/**"
|
- "src/AcDream.Core/**"
|
||||||
- "src/AcDream.Core.Net/**"
|
- "src/AcDream.Core.Net/**"
|
||||||
- "src/AcDream.Content/**"
|
- "src/AcDream.Content/**"
|
||||||
|
|
@ -34,6 +47,10 @@ on:
|
||||||
- "src/AcDream.Headless/**"
|
- "src/AcDream.Headless/**"
|
||||||
- "src/AcDream.App/**"
|
- "src/AcDream.App/**"
|
||||||
- "src/AcDream.UI.Abstractions/**"
|
- "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.Tests/**"
|
||||||
- "tests/AcDream.Core.Net.Tests/**"
|
- "tests/AcDream.Core.Net.Tests/**"
|
||||||
- "tests/AcDream.Content.Tests/**"
|
- "tests/AcDream.Content.Tests/**"
|
||||||
|
|
@ -41,6 +58,7 @@ on:
|
||||||
- "tests/AcDream.Headless.Tests/**"
|
- "tests/AcDream.Headless.Tests/**"
|
||||||
- "tests/AcDream.App.Tests/**"
|
- "tests/AcDream.App.Tests/**"
|
||||||
- "tests/AcDream.UI.Abstractions.Tests/**"
|
- "tests/AcDream.UI.Abstractions.Tests/**"
|
||||||
|
- "tests/Fixtures/campaign-la/**"
|
||||||
- "tools/ShaderCompiler/**"
|
- "tools/ShaderCompiler/**"
|
||||||
- "tools/compile-shaders.ps1"
|
- "tools/compile-shaders.ps1"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
@ -66,7 +84,7 @@ jobs:
|
||||||
dotnet-version: "10.0.x"
|
dotnet-version: "10.0.x"
|
||||||
|
|
||||||
# No apt step here on purpose. This job's whole claim is that the closure
|
# 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
|
# Core.Net, Content, Runtime and Headless, runs their tests, and invokes
|
||||||
# the Headless CLI. Nothing in it opens a display, links GL, or calls
|
# 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
|
# xvfb-run, so an "install the graphical smoke dependencies" step here was
|
||||||
|
|
@ -78,6 +96,9 @@ jobs:
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$projects = @(
|
$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.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj",
|
||||||
"src/AcDream.Core/AcDream.Core.csproj",
|
"src/AcDream.Core/AcDream.Core.csproj",
|
||||||
"src/AcDream.Core.Net/AcDream.Core.Net.csproj",
|
"src/AcDream.Core.Net/AcDream.Core.Net.csproj",
|
||||||
|
|
@ -98,6 +119,9 @@ jobs:
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$projects = @(
|
$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.Core.Net.Tests/AcDream.Core.Net.Tests.csproj",
|
||||||
"tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj",
|
"tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj",
|
||||||
"tests/AcDream.Runtime.Tests/AcDream.Runtime.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
|
dotnet run --project src/AcDream.Headless/AcDream.Headless.csproj -c Release -- validate --config headless-k0.json
|
||||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
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:
|
linux-graphical:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
|
|
||||||
10
AcDream.slnx
10
AcDream.slnx
|
|
@ -7,6 +7,9 @@
|
||||||
<Project Path="src/AcDream.Core/AcDream.Core.csproj" />
|
<Project Path="src/AcDream.Core/AcDream.Core.csproj" />
|
||||||
<Project Path="src/AcDream.Core.Net/AcDream.Core.Net.csproj" />
|
<Project Path="src/AcDream.Core.Net/AcDream.Core.Net.csproj" />
|
||||||
<Project Path="src/AcDream.Headless/AcDream.Headless.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.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj" />
|
||||||
<Project Path="src/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj" />
|
<Project Path="src/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj" />
|
||||||
<Project Path="src/AcDream.Runtime/AcDream.Runtime.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.Tests/AcDream.Core.Tests.csproj" />
|
||||||
<Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.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.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.Runtime.Tests/AcDream.Runtime.Tests.csproj" />
|
||||||
<Project Path="tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj" />
|
<Project Path="tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
|
|
|
||||||
60
CLAUDE.md
60
CLAUDE.md
|
|
@ -232,6 +232,66 @@ J-owner, both retail open paths, staged-item trading marker
|
||||||
AD-93/AD-94 filed, AD-85 narrowed, AD-81 amended, AD-89/AD-95 retired.
|
AD-93/AD-94 filed, AD-85 narrowed, AD-81 amended, AD-89/AD-95 retired.
|
||||||
Filed: #393 (texture-detail options, post-M4).
|
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-215–AP-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 cutover — C4 COMPLETE 2026-08-05, merged to main.** Every
|
||||||
placement route now runs through the canonical residence + continuation-
|
placement route now runs through the canonical residence + continuation-
|
||||||
executor owner. Routes landed this session: 4b-3 remote teleport/cell-less
|
executor owner. Routes landed this session: 4b-3 remote teleport/cell-less
|
||||||
|
|
|
||||||
630
docs/ISSUES.md
630
docs/ISSUES.md
|
|
@ -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.
|
- 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.
|
- 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
|
## #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
|
**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
|
## #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
|
**Status:** OPEN, NARROWED 2026-08-16 at Campaign CC gate round 1 Batch C
|
||||||
chat window authors a 16×16 "new unseen text" indicator button
|
Commit 2 — the BUILD half of this issue's own "fix shape" recommendation is
|
||||||
(`0x1000048C`, base `0x10000527`/`0x21000040`) as a CHILD of the transcript
|
now DONE. `LayoutImporter.BuildWidget` gained a `UiText`/`UiField`
|
||||||
text element `0x10000011` (position `(0,57)` relative to the transcript,
|
media-bearing-child carve-out (mirroring `UiMeter`'s own text-overlay
|
||||||
i.e. bottom-left of the transcript pane), confirmed in the `0x2100006F`
|
carve-out, EXACTLY the shape this issue proposed) as part of a chargen
|
||||||
LayoutDesc dump. `UiText.ConsumesDatChildren` is `true` (Type-12 behavioral
|
description-box fix; the client-wide blast-radius sweep that fix's own
|
||||||
widgets reproduce their dat sub-elements procedurally per
|
tests run
|
||||||
`DatWidgetFactory`'s own doc comment), so `LayoutImporter.BuildWidget`
|
(`LayoutImporterMediaBearingChildSweepTests.MediaBearingChildSweep_EnumeratesEveryAffectedType12Element`)
|
||||||
never builds `0x1000048C` as a separate widget — it is silently swallowed,
|
independently re-confirmed `0x1000048C` under `0x10000011` in layout
|
||||||
same as under the wrong `0x21000006` layout before it (not a CH6a
|
`0x2100006F` as one of the affected elements — it now builds as a real
|
||||||
regression). No controller anywhere binds or drives its visible state.
|
widget instead of being silently swallowed. **Still open:** no controller
|
||||||
**Not in CH6a's scope** (transcript/input/scrollbar/1-4-buttons only) and
|
binds or drives its visible state (STILL the original ask — what triggers
|
||||||
not obviously CH6b/CH6c's either — files here as a standalone gap. Fix
|
retail's "new text" indicator, and what it does on click, remains
|
||||||
shape: either give `UiText` an opt-in mechanism to keep specific named
|
un-researched); this issue stays open for that behavioral half.
|
||||||
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.
|
|
||||||
|
|
||||||
**Where:** `src/AcDream.App/UI/Layout/ChatWindowController.cs`;
|
**Where:** `src/AcDream.App/UI/Layout/ChatWindowController.cs` (behavior,
|
||||||
`src/AcDream.App/UI/Layout/LayoutImporter.cs`
|
still missing); `src/AcDream.App/UI/Layout/LayoutImporter.cs`
|
||||||
(`BuildWidget`/`ConsumesDatChildren` handling); `src/AcDream.App/UI/UiText.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
|
## #367 — ChatCommandRouter's local-presentation fallbacks type-0x1A text still lands in the chat scroll, never the SpewBox
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ stack. Full history and the corrected contract live in
|
||||||
│ LayoutDesc/DAT → UiRoot retained widgets + controllers │
|
│ LayoutDesc/DAT → UiRoot retained widgets + controllers │
|
||||||
├─────────────────────────────────────────────────────────────┤
|
├─────────────────────────────────────────────────────────────┤
|
||||||
│ SHARED CONTRACTS │
|
│ 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 │
|
│ ► one model and mutation path, one presentation projection │
|
||||||
├─────────────────────────────────────────────────────────────┤
|
├─────────────────────────────────────────────────────────────┤
|
||||||
│ Game state + events (unchanged) │
|
│ 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
|
`AcDream.UI.Abstractions` — the `IPanel`/`IPanelRenderer` contract and the
|
||||||
ViewModels and the commands — **survives intact**. It was always
|
ViewModels — **survives intact**. It was always
|
||||||
backend-agnostic, which is exactly what Code Structure Rule 3 was written to
|
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
|
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
|
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
|
debug-utils extensions and now logs that the developer UI is gone; replacing it
|
||||||
is issue **#258**, deliberately unscheduled.
|
is issue **#258**, deliberately unscheduled.
|
||||||
|
|
||||||
`AcDream.UI.Abstractions` owns backend-neutral ViewModels, commands, input,
|
`AcDream.UI.Abstractions` owns backend-neutral ViewModels, input, and the
|
||||||
and the `IPanel`/`IPanelRenderer` devtools contract. `AcDream.App/UI` owns the
|
`IPanel`/`IPanelRenderer` devtools contract. `AcDream.App/UI` owns the
|
||||||
retained gameplay tree, LayoutDesc importer, window runtime, and panel
|
retained gameplay tree, LayoutDesc importer, window runtime, and panel
|
||||||
controllers. Neither presentation stack owns independent game-state truth.
|
controllers. Neither presentation stack owns independent game-state truth.
|
||||||
Chat submission follows the same rule: both presentation stacks enter the
|
Chat submission follows the same rule: `AcDream.Runtime/Chat` owns the shared
|
||||||
shared `ChatCommandRouter`, which emits distinct backend-neutral intents for a
|
parser, retail command/channel/help catalogs, `ChatCommandRouter`, command bus,
|
||||||
retail client command (`ExecuteClientCommandCmd`), an ACE-owned command
|
and its four backend-neutral records (`ExecuteClientCommandCmd`,
|
||||||
(`SendServerCommandCmd`), or ordinary chat (`SendChatCmd`). App-layer
|
`SendServerCommandCmd`, `SendChatCmd`, and `SendRawChannelCmd`). Its only
|
||||||
handlers and controllers translate those intents to `WorldSession`; panels
|
presentation callback is the four-member `IChatCommandFeedback`; retained
|
||||||
never inspect or construct wire messages.
|
`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
|
Plugins register retained gameplay markup through the BCL-only
|
||||||
`AcDream.Plugin.Abstractions.IUiRegistry`; they do not import App or
|
`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;
|
radar, inventory, paperdoll, toolbar, use/examine consumers, and plugins;
|
||||||
`IPluginHost.Selection` exposes that same state and retail-style old/new callback.
|
`IPluginHost.Selection` exposes that same state and retail-style old/new callback.
|
||||||
Temporary pointer modes are separate App orchestration in `InteractionState` and
|
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
|
retained tick/draw/restore/dispose paths. Panel-specific construction must not
|
||||||
move back into `GameWindow.OnLoad`.
|
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/
|
Magic follows the same boundary. Core `Spellbook` is the one learned/favorite/
|
||||||
desired/enchantment state projection; Core.Net owns exact manifest and live
|
desired/enchantment state projection; Core.Net owns exact manifest and live
|
||||||
message parsing; Runtime `RuntimeActionState.SpellCast` owns validated cast
|
message parsing; Runtime `RuntimeActionState.SpellCast` owns validated cast
|
||||||
|
|
@ -174,6 +201,9 @@ parallel window-lifecycle map.
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
AcDream.Core/ Layer 2-4: no Vulkan, no Silk.NET, pure logic
|
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/
|
Physics/
|
||||||
PhysicsBody.cs -> body state / integration foundation (done)
|
PhysicsBody.cs -> body state / integration foundation (done)
|
||||||
CollisionPrimitives.cs -> retail primitive helpers (partial, active)
|
CollisionPrimitives.cs -> retail primitive helpers (partial, active)
|
||||||
|
|
@ -219,6 +249,9 @@ src/
|
||||||
generation + teardown
|
generation + teardown
|
||||||
Session/ -> J2 canonical session lifetime, ordered
|
Session/ -> J2 canonical session lifetime, ordered
|
||||||
inbound routing + retryable teardown
|
inbound routing + retryable teardown
|
||||||
|
RuntimeCharacterSelectionState.cs -> sole generation-scoped pre-world
|
||||||
|
roster/highlight/delete/restore/error owner;
|
||||||
|
borrowed view + ordered deltas + typed commands
|
||||||
Entities/
|
Entities/
|
||||||
RuntimeEntityDirectory.cs -> sole GUID/incarnation/local-ID authority
|
RuntimeEntityDirectory.cs -> sole GUID/incarnation/local-ID authority
|
||||||
RuntimeEntityRecord.cs -> presentation-free accepted entity state
|
RuntimeEntityRecord.cs -> presentation-free accepted entity state
|
||||||
|
|
@ -236,6 +269,9 @@ src/
|
||||||
RuntimeInitialCreateContinuationExecutor.cs -> retry-idempotent
|
RuntimeInitialCreateContinuationExecutor.cs -> retry-idempotent
|
||||||
adoption + retail Create tail + strict-order
|
adoption + retail Create tail + strict-order
|
||||||
FIFO/replay execution over the residence
|
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/
|
Gameplay/
|
||||||
RuntimeCommunicationState.cs -> one chat/social owner + ordered stream
|
RuntimeCommunicationState.cs -> one chat/social owner + ordered stream
|
||||||
RuntimeInventoryState.cs -> exact object-table borrower + inventory
|
RuntimeInventoryState.cs -> exact object-table borrower + inventory
|
||||||
|
|
@ -266,19 +302,83 @@ src/
|
||||||
World/
|
World/
|
||||||
RuntimeWorldEnvironmentState.cs -> canonical calendar/time/weather owner
|
RuntimeWorldEnvironmentState.cs -> canonical calendar/time/weather owner
|
||||||
RuntimeWorldTransitState.cs -> canonical reveal generation/readiness 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
|
RuntimeGenerationReset.cs -> one retryable canonical-generation reset
|
||||||
-> Slice J complete; graphical and no-window hosts share one GameRuntime
|
-> 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
|
-> 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
|
AcDream.Headless/ Linux/Windows no-window production host
|
||||||
Program.cs -> CLI entry only
|
Program.cs -> CLI entry only
|
||||||
Configuration/ -> strict versioned process/session config
|
Configuration/ -> strict versioned process/session config
|
||||||
Credentials/ -> redacted env/stdin/owner-only-file providers
|
Credentials/ -> redacted env/stdin/owner-only-file providers
|
||||||
Hosting/ -> one GameRuntime/session/lease/policy lifetime
|
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
|
Policies/ -> typed Runtime-view/command consumers
|
||||||
-> references Runtime only; no presentation/backend package
|
-> references Runtime only; no presentation/backend package
|
||||||
-> Slice K complete: portable single/multi-session production host,
|
-> Slice K complete: portable single/multi-session production host,
|
||||||
|
|
@ -289,6 +389,7 @@ src/
|
||||||
AcDream.Plugin.Abstractions/ Layer 5: plugin interfaces
|
AcDream.Plugin.Abstractions/ Layer 5: plugin interfaces
|
||||||
IAcDreamPlugin.cs -> done
|
IAcDreamPlugin.cs -> done
|
||||||
IPluginHost.cs -> done
|
IPluginHost.cs -> done
|
||||||
|
IUiRegistry.cs -> capability-aware retained/no-op UI contract
|
||||||
IGameState.cs -> done
|
IGameState.cs -> done
|
||||||
IEvents.cs -> done
|
IEvents.cs -> done
|
||||||
ISelectionService.cs -> done
|
ISelectionService.cs -> done
|
||||||
|
|
@ -343,6 +444,7 @@ src/
|
||||||
PlayerMovementController.cs -> active movement driver
|
PlayerMovementController.cs -> active movement driver
|
||||||
Plugins/
|
Plugins/
|
||||||
AppPluginHost.cs -> done
|
AppPluginHost.cs -> done
|
||||||
|
GraphicalPluginSession.cs -> thin shared-session/root/status adapter
|
||||||
```
|
```
|
||||||
|
|
||||||
The 4B2 production SetPosition routes and shared local-controller body remain
|
The 4B2 production SetPosition routes and shared local-controller body remain
|
||||||
|
|
|
||||||
|
|
@ -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
|
writes against `IPanelRenderer`; a renderer implementation translates those
|
||||||
calls at runtime. Plugin-facing UI follows the same rule.
|
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 —
|
**Status:** there is currently no `IPanelRenderer` implementation in the tree —
|
||||||
the ImGui one went with V11 and the replacement is issue **#258**. The contract
|
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
|
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 |
|
| Area | Status | Current truth |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Startup options | **Complete** | `RuntimeOptions` owns startup configuration (`eda936dc`). Remaining direct environment reads are legacy runtime diagnostics, not startup configuration. |
|
| 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. |
|
| 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`). |
|
| 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`). |
|
| 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
|
|
@ -1,6 +1,6 @@
|
||||||
# acdream — strategic roadmap
|
# 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.
|
**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
|
**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:
|
in-client acceptance script:
|
||||||
[`2026-08-09-campaign-ch-test-script.md`](../research/2026-08-09-campaign-ch-test-script.md).
|
[`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
|
**Remaining physics-divergence closeout (ACTIVE, checkpoint 2026-08-03):** the user then
|
||||||
authorized retirement of the remaining proven collision/placement gaps before
|
authorized retirement of the remaining proven collision/placement gaps before
|
||||||
vendor work resumes. Nested retry, edge/StepDown/Path-6 ordering, exact cell
|
vendor work resumes. Nested retry, edge/StepDown/Path-6 ordering, exact cell
|
||||||
|
|
|
||||||
860
docs/plans/2026-08-14-launcher-campaign.md
Normal file
860
docs/plans/2026-08-14-launcher-campaign.md
Normal 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 3–4 agents in parallel including children; subagents never spawn
|
||||||
|
subagents; implementer prompts carry spec+plan paths, files-to-read,
|
||||||
|
acceptance criteria, commit style.
|
||||||
|
- `dotnet build` + `dotnet test` green before a slice is DONE; ≥1 commit per
|
||||||
|
slice tagged `Campaign LA`; retail deviations add their
|
||||||
|
`docs/architecture/retail-divergence-register.md` row in the same commit;
|
||||||
|
no workarounds without explicit user approval.
|
||||||
|
- Connected/visual gates are the ONLY stop-and-wait points; each gets an
|
||||||
|
exact script under `docs/research/` and non-blocked slices keep moving.
|
||||||
|
|
||||||
|
## Slice map
|
||||||
|
|
||||||
|
| Slice | Deliverable | Depends on |
|
||||||
|
|---|---|---|
|
||||||
|
| LA0 | `AcDream.Platform` extraction (`ApplicationPathSet`) + guard amendments | — |
|
||||||
|
| LA1 | Launch contract: App `--session-config` + stdin credential; status.jsonl writer both hosts; roster plumbing | LA0 |
|
||||||
|
| LA2 | Headless probe mode + `idle` policy | LA1 |
|
||||||
|
| LA3 | `AcDream.Launcher.Core`: profile store CRUD, config composition, spawn/supervise, status reader | LA0 (LA1 contract shapes) |
|
||||||
|
| LA4 | `AcDream.Launcher` Avalonia UI: CRUD views, per-char settings, sessions, probe action | LA3 |
|
||||||
|
| LA5 | Plugin hosting: headless `IPluginHost` + capability flag; session-driven plugin set both hosts | LA1 |
|
||||||
|
| LA6 | Login commands: parser-core extraction + execution on both hosts | LA1, LA5 |
|
||||||
|
| LA7 | Character-select: Runtime selection state + wire (delete/restore/error) + no-selector flow | LA1 |
|
||||||
|
| LA8 | Character-select authored retail screen (flat listbox — NO 3D preview, recon-corrected) | LA7 |
|
||||||
|
| LA9 | Installer: first-run wizard (DAT locate/validate, bake w/ progress, SHA record) | LA3, LA4 |
|
||||||
|
| LA10 | Updater: GitHub Releases manifest, download/verify/install/swap, self-update | LA3, LA4 |
|
||||||
|
| LA11 | Closeout: connected-gate script, roadmap/CLAUDE.md/memory, program ledger | all |
|
||||||
|
|
||||||
|
Parallelism guide: LA3/LA4 (launcher side) proceed alongside LA5–LA8 (client
|
||||||
|
side) — different assemblies, no shared files. LA9/LA10 close the launcher
|
||||||
|
side; LA11 closes the campaign.
|
||||||
|
|
||||||
|
## Linux posture (binding — user decision 2026-08-14)
|
||||||
|
|
||||||
|
Everything the launcher does must WORK ON LINUX in this campaign, except
|
||||||
|
GUI client launches: the Linux graphical client is Slice L, parked at L1,
|
||||||
|
resuming later ("ok we will do it later"). Concretely:
|
||||||
|
|
||||||
|
- **Linux-shipping in LA:** the Avalonia launcher UI, profile CRUD +
|
||||||
|
0600-permission file, installer (manual DAT picker — the auto-detect
|
||||||
|
paths are Windows-only; `acdream-bake` is GL-free and runs on Linux),
|
||||||
|
updater (staged swap; Linux can replace a running binary but keep the
|
||||||
|
same staged-atomic flow), headless launches with plugins + login
|
||||||
|
commands, and the character probe.
|
||||||
|
- **Launcher UX on Linux:** the `gui` / `guiSelect` launch modes render
|
||||||
|
disabled with an explicit "requires the Linux graphical client (Slice
|
||||||
|
L)" note — never a silent failure.
|
||||||
|
- **Per-slice enforcement:** every slice touching Launcher.Core, Headless,
|
||||||
|
Runtime, Bake, or Platform runs its test projects on Linux (native
|
||||||
|
Ubuntu or WSL, matching the K-slice practice) before the slice is DONE;
|
||||||
|
LA4/LA9/LA10 additionally prove a real `linux-x64` self-contained
|
||||||
|
publish. LA11's connected-gate script gets a Linux section: launcher on
|
||||||
|
Ubuntu doing CRUD, probe, headless launch with plugin + login commands,
|
||||||
|
first-run install with a manual DAT path, and an update swap.
|
||||||
|
- When Slice L later ships, the launcher's Linux GUI modes light up with
|
||||||
|
NO launcher changes (the session-config contract is host-agnostic) —
|
||||||
|
that expectation is part of LA's design acceptance.
|
||||||
|
|
||||||
|
## LA0 — `AcDream.Platform` extraction
|
||||||
|
|
||||||
|
New BCL-only project `src/AcDream.Platform/` holding `ApplicationPathSet` +
|
||||||
|
`IApplicationPathEnvironment` (today
|
||||||
|
`src/AcDream.Runtime/Platform/ApplicationPathSet.cs` — self-contained, no
|
||||||
|
intra-Runtime dependencies; clean cut). Runtime/App/Headless reference it.
|
||||||
|
|
||||||
|
Recon facts (2026-08-14): blast radius is the definition, six source files
|
||||||
|
(`GraphicalHostPlatformServices.cs`, `GraphicalLegacyConfigurationMigrator.cs`,
|
||||||
|
`App/Program.cs`, `GameWindow.cs:533`, `HeadlessPathSet.cs`,
|
||||||
|
`HeadlessPlatformEnvironment.cs`; two more files are doc-comment-only), two
|
||||||
|
test files (`ApplicationPathSetTests.cs` moves to a new
|
||||||
|
`tests/AcDream.Platform.Tests/`;
|
||||||
|
`GraphicalLegacyConfigurationMigratorTests.cs` fixtures), and the dependency
|
||||||
|
guards — CORRECTED post-review (the original recon here asserted the wrong
|
||||||
|
guard, the C4-closeout failure mode): the K0 Headless guard
|
||||||
|
(`HeadlessAssemblyReferencesOnlyTheRuntimeProject`) asserts HEADLESS's own
|
||||||
|
csproj reference list, which this move does not touch — it stays UNCHANGED;
|
||||||
|
the guard that actually needs amending is Runtime's own
|
||||||
|
`RuntimeDependencyBoundaryTests.RuntimeProjectDeclaresOnlyApprovedProjectDependencies`
|
||||||
|
(Runtime gains the `AcDream.Platform` reference), amended with a cited
|
||||||
|
comment in the same commit. Namespace stays `AcDream.Runtime.Platform`?
|
||||||
|
NO — rename to `AcDream.Platform` and fix the eight usings (clean naming beats
|
||||||
|
avoiding a mechanical edit). Register new projects in `AcDream.slnx`.
|
||||||
|
|
||||||
|
**Acceptance:** build + full test suite green; guard test asserts the new
|
||||||
|
exact reference set; launcher-side consumability proven by the LA3 project
|
||||||
|
referencing only `AcDream.Platform`.
|
||||||
|
|
||||||
|
## LA1 — launch contract (client side)
|
||||||
|
|
||||||
|
### Pinned launch-contract schema (v1, BINDING — committed per LA3 review)
|
||||||
|
|
||||||
|
This text is the single source of truth for the launcher↔host file
|
||||||
|
contract. Both host readers (LA1), the composer (LA3), and the probe
|
||||||
|
loader (LA2) implement EXACTLY this; any change is an amendment to THIS
|
||||||
|
section first, implementations second. The LA1+LA3 merge adds a
|
||||||
|
cross-assembly test feeding a composer-produced document to both host
|
||||||
|
loaders — that test is the seam's permanent enforcement.
|
||||||
|
|
||||||
|
Session-config document (System.Text.Json, camelCase,
|
||||||
|
`UnmappedMemberHandling.Disallow`, camelCase string enums):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"process": {
|
||||||
|
"content": { "datDirectory": "...", "preparedAssetPath": "..." }
|
||||||
|
},
|
||||||
|
"sessions": [{
|
||||||
|
"id": "sess-1",
|
||||||
|
"endpoint": { "host": "127.0.0.1", "port": 9000 },
|
||||||
|
"account": "testaccount",
|
||||||
|
"mode": "probe",
|
||||||
|
"character": { "id": 1342177290 },
|
||||||
|
"policy": { "id": "idle" },
|
||||||
|
"credential": { "provider": "standardInput", "reference": "session" },
|
||||||
|
"plugins": ["ExamplePlugin"],
|
||||||
|
"loginCommands": ["/vt start"],
|
||||||
|
"loginCommandDelayMs": 500,
|
||||||
|
"statusFile": ".../launcher/sessions/sess-1/status.jsonl"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Field rules:
|
||||||
|
- `process.paths` is OMITTED unless a caller genuinely supplies overrides
|
||||||
|
(never an empty object — the App reader has no `paths` member and
|
||||||
|
strict parsing rejects unknown keys; LA3 review finding 1).
|
||||||
|
- `mode`: ABSENT for normal play sessions; `"probe"` for the LA2 probe
|
||||||
|
(connect → characterList → graceful disconnect, no EnterWorld). The
|
||||||
|
headless loader accepts the field starting at LA2.
|
||||||
|
- `character`: exactly ONE of index|id|name; OMITTED entirely (not null)
|
||||||
|
for guiSelect and for probe sessions.
|
||||||
|
- `policy`: `{ "id": "idle" }` for headless play sessions ONLY; omitted
|
||||||
|
for gui/guiSelect/probe.
|
||||||
|
- `credential`: always `{ "provider": "standardInput", "reference":
|
||||||
|
"session" }` for launcher-composed configs.
|
||||||
|
- `plugins`: absent/null means load all discovered plugins (preserving the
|
||||||
|
developer flow); explicit `[]` means load none. Launcher-composed
|
||||||
|
normal-empty and probe sessions emit `[]` so they cannot load arbitrary
|
||||||
|
machine-local plugins.
|
||||||
|
- `loginCommands`/`loginCommandDelayMs`/`statusFile`: optional,
|
||||||
|
omitted-when-unset (never null, never `[]` for empty). Absent
|
||||||
|
`loginCommandDelayMs` means 500.
|
||||||
|
|
||||||
|
Status stream (`statusFile`, one JSON object per line, writer flushes per
|
||||||
|
line, writer opens `FileShare.Read`, tailer opens
|
||||||
|
`Read/FileShare.ReadWrite|Delete`): events `started`, `connected`,
|
||||||
|
`characterList{accountName,slotCount,characters[{id,name,secondsGreyedOut}]}`,
|
||||||
|
`enteredWorld{characterId,characterName}`, `pluginLoaded{plugin}`,
|
||||||
|
`pluginFailed{plugin,error}`,
|
||||||
|
`loginCommandFailed{commandIndex,command,error}`,
|
||||||
|
`characterCreated{guid,name}`, `creationFailed{code,reason,name}`,
|
||||||
|
`disconnected{reason}`,
|
||||||
|
`exited{code,reason}` — every line carries `"v":1`, `"e"`, `"t"`
|
||||||
|
(ISO-8601 UTC), `"sessionId"`. `secondsGreyedOut` is a uint on BOTH
|
||||||
|
sides. Unknown `e` values must parse to a typed Unknown event, never
|
||||||
|
throw; a known `e` with a wrong payload shape should be distinguishable
|
||||||
|
from an unknown `e` (LA3 review finding 12).
|
||||||
|
|
||||||
|
**Campaign CC CC2 amendment (this section is the contract; the writer and
|
||||||
|
tailer below implement it, in that order):** `characterCreated{guid,name}`
|
||||||
|
fires on the Ok reply to a `CharacterCreate` (opcode `0xF656`) request —
|
||||||
|
`guid`/`name` come straight off the shared `0xF643`
|
||||||
|
`CharGenVerificationResponse` Ok identity payload
|
||||||
|
(`AcDream.Core.Net.Messages.CharGenVerificationResponse`), deliberately
|
||||||
|
named `guid`/`name` rather than `characterId`/`characterName` to mirror
|
||||||
|
that payload's own field names and to read distinctly from
|
||||||
|
`enteredWorld` — a freshly created character is logged straight in by
|
||||||
|
retail without a fresh `characterList` (see that type's doc comment), so
|
||||||
|
`characterCreated` can precede an `enteredWorld` for the same character
|
||||||
|
rather than replacing it. `creationFailed{code,reason,name}` fires on any
|
||||||
|
non-Ok reply: `code` is the raw wire `CharGenVerificationResponse.Code`
|
||||||
|
value, `reason` is that code's enum member name (e.g. `"NameInUse"`) so a
|
||||||
|
reader gets a stable readable reason without hard-coding the numeric
|
||||||
|
mapping itself, and `name` is the ATTEMPTED character name so a launcher
|
||||||
|
can render "the name Bob is taken". (CC2 review F4: the enum member
|
||||||
|
originally rode the `name` key, colliding in meaning with
|
||||||
|
`characterCreated.name`; renamed before any consumer shipped.)
|
||||||
|
|
||||||
|
`loginCommandFailed.commandIndex` is the zero-based index in the configured
|
||||||
|
`loginCommands` array. `command` is the exact configured line and `error` is
|
||||||
|
the isolated parser/router/handler failure. The event is observational: the
|
||||||
|
host continues with the next configured line and never converts the command
|
||||||
|
failure into a login, plugin, session, or process failure.
|
||||||
|
|
||||||
|
**Known LA1 status limitation:** the stream has no independent mid-play
|
||||||
|
wire-drop detector. If a transport becomes silent without raising through the
|
||||||
|
host's tick/teardown path, no immediate `disconnected` line can be promised;
|
||||||
|
the launcher must not treat the absence of that line as proof that the socket
|
||||||
|
is healthy. Explicit reconnect is ordered and observable — it emits
|
||||||
|
`disconnected{reason:"reconnect"}` before the replacement connection's second
|
||||||
|
`connected` — and normal stop/process teardown closes any still-open
|
||||||
|
connection before `exited`. A future transport-health signal may improve the
|
||||||
|
timing without changing this pinned event vocabulary.
|
||||||
|
|
||||||
|
Three pieces, one slice, because they share the session-config/status seam:
|
||||||
|
|
||||||
|
1. **App `--session-config <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 §A–I
|
||||||
|
connected script remains the open user gate.
|
||||||
|
|
||||||
|
## Gate round 2 — 2026-08-15 (first live launcher→client flow) — char-select matrix USER-PASSED
|
||||||
|
|
||||||
|
**USER-PASSED 2026-08-15 (end of round):** the character-select visual/
|
||||||
|
interaction matrix — stretched-canvas look with bilinear filtering,
|
||||||
|
aligned widgets, left-justified roster, World box reading the live server
|
||||||
|
name ("sawato"), and the centered exit confirmation — all accepted on the
|
||||||
|
live launcher→client flow. Round-2 commits after the round-1 batch:
|
||||||
|
`6e1c0967` (session-config launches force the retail UI), `9ce72925`
|
||||||
|
(PFID_CUSTOM_RAW_JPEG decode + resolution guards), `73041d70`
|
||||||
|
(whole-canvas AD-98 scale + inverse input), `308f40a3` (linear-twin
|
||||||
|
bilinear stretch), `ef96c554` (exit confirmation + authored justify +
|
||||||
|
world name, AD-99), `2e6d69dd` (#400), `0a7dc7d6` (durable world-name
|
||||||
|
read + canvas-centered dialogs). Remaining before shipment: the formal
|
||||||
|
§A–I script rows (probe ×2, headless+plugins+login commands, delete/
|
||||||
|
restore, A→B update swap, row I Linux), and the final-HEAD preflight
|
||||||
|
re-run.
|
||||||
|
|
||||||
|
**Round REVIEW-CLOSED 2026-08-15:** the owed Opus dual-lens batch review
|
||||||
|
of the six round-2 commits returned PASS with 8 findings; the fix round
|
||||||
|
(`0baebce2` — headline: `RetailWaitDialogView` was the ONE dialog view the
|
||||||
|
EffectiveCanvasSize sweep missed, firing on ENTER; plus the two stale
|
||||||
|
deleted-mechanism doc assertions, the Confirmation `0xAC` property,
|
||||||
|
truncating input mapping, the IsCurrent world-name gate, the AD-98
|
||||||
|
evidence note) closed all seven in the narrow re-review; F2 filed as
|
||||||
|
#401 (invert RetailUi to opt-out). The review also proved the
|
||||||
|
`DatWidgetFactory` justify widening has ZERO regressions across all 35
|
||||||
|
layout fixtures (303 buttons swept; the 16 authored-Left all already
|
||||||
|
left-aligned via their face-child branch) and is a move TOWARD retail
|
||||||
|
(`CalcJustification @0x00467260` has no lifted-from-child condition).
|
||||||
|
|
||||||
|
Two real defects, both root-caused and fixed:
|
||||||
|
|
||||||
|
1. **`6e1c0967` — launcher-spawned clients had NO interface at all.**
|
||||||
|
`RetailUi` rode the dev env var `ACDREAM_RETAIL_UI`; `FromSessionConfig`
|
||||||
|
inherited the env parse; the launcher strips `ACDREAM_*` from children
|
||||||
|
(LA11 isolation). Product launches therefore got the dev default: world
|
||||||
|
rendering, zero UI — character screen included. Session-config launches
|
||||||
|
now force `RetailUi = true` (a session-config launch IS a product
|
||||||
|
launch); the env flag remains the dev-launch opt-in. Test-pinned with a
|
||||||
|
null env.
|
||||||
|
2. **`9ce72925` — character-select screen rendered magenta background/
|
||||||
|
fills.** The screen's 800×600 root background (`0x06007576`) is
|
||||||
|
`PFID_CUSTOM_RAW_JPEG` — a complete JFIF stream retail hands to the
|
||||||
|
Intel JPEG Library (`RenderSurface::CreateFromSourceData @0x004440a0`),
|
||||||
|
with Width/Height legitimately 0 on disk. `SurfaceDecoder` had no JPEG
|
||||||
|
case AND a non-positive-dimension guard, so it fell silently to the
|
||||||
|
magenta placeholder; the listbox/ENTER fills are transparent, so one
|
||||||
|
broken background bled through as three symptoms. Fixed via
|
||||||
|
StbImageSharp (managed, Linux-safe; codec-library substitution per the
|
||||||
|
BCnEncoder precedent — no register row). BOTH silent traps now log once
|
||||||
|
per id (id-resolves-but-undecodable in `SurfaceDecoder`;
|
||||||
|
id-missing-from-DATs in `TextureCache`) — the existing magenta guard
|
||||||
|
only covered id-0. New installed-DAT sweep asserts every char-select
|
||||||
|
media id decodes non-magenta. Full suite 14,034 green.
|
||||||
|
|
||||||
|
Session-orchestration facts this round: the machine gained PowerShell 7
|
||||||
|
(winget, user-approved — the LA fixture tooling hard-requires it); an
|
||||||
|
orphan feed server from the earlier session held port 43119 with stale
|
||||||
|
fixture data (stopped); the launcher self-update bootstrap restart on a
|
||||||
|
dev binary is EXPECTED (staged launcher update → exit → respawn).
|
||||||
|
Observations still open for this round: the duplicated "versioned client
|
||||||
|
is unavailable" status line (cosmetic), and verifying Create Character is
|
||||||
|
disabled on the live screen.
|
||||||
|
|
||||||
|
## Ledger
|
||||||
|
|
||||||
|
| Slice | Status | Commits | Review | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| LA0 | **DONE 2026-08-14** | `cb6502c8`, `a49e92df` | Opus dual-lens PASS; all 6 findings CLOSED in narrow re-review | Byte-identity proven; Linux CI lanes restored; Platform BCL-only self-guard added; K0 guard untouched |
|
||||||
|
| LA1 | **DONE 2026-08-14** | `db9ad53c` (mixed — see `e1322a06`), `75a6724d` (recovery WIP), `d511e4c3`, ledger `890cf267` | Initial review FIX FIRST; F1–F8 CLOSED; narrow dual-lens re-review PASS | Release build green (0 errors / 18 warnings). Windows: Runtime 1634 / Headless 127 / App 5038+3skip. WSL: Runtime 1634 / Headless 127. Known mid-play silent-wire-drop limitation recorded above. The LA1+LA3 composer-to-both-hosts contract gate and portable CI lane landed at `8a03a25f`. |
|
||||||
|
| LA2 | **DONE + MERGED 2026-08-14** | `c6019424` (recovery WIP), `000ea979`, `1c5e66c0`, merge `e01b2cd1` | Dual-lens review FIX FIRST; all 3 findings CLOSED; final narrow re-review PASS | Probe success requires a reported roster and remains before selection/EnterWorld; terminal status derives from the actual start outcome; conditional fields distinguish omission from explicit null without weakening strict JSON. Branch gates: Runtime 1,632/1,632 and Headless 149/149 on both Windows and Ubuntu/WSL. Integrated gates: Release solution build green; Windows Runtime 1,636/1,636, Headless 151/151, App 5,039+3 skip, Launcher.Core 114/114; WSL Runtime 1,636/1,636, Headless 151/151, Launcher.Core 114/114. Repeated live ACE probe remains the LA11 user gate. |
|
||||||
|
| LA3 | **DONE + MERGED 2026-08-14** | `37d74e44`, `26feba81`, `347a1a5d`, merge `7749545d`, seam `8a03a25f` | Initial 12 findings CLOSED; four-gap narrow review FIX FIRST; final narrow re-review PASS | `AcDream.Launcher.Core` remains BCL + Platform only. Windows/WSL Core 114/114; full Release build green. Composer output is parsed by BOTH real host loaders from one linked fixture; Launcher.Core build/tests run in the portable Windows+Ubuntu lane. Windows graceful-stop gap remains tracked as #397. |
|
||||||
|
| LA4 | **DONE + MERGED 2026-08-14** | `d0a9c65d`, `10a712d6`, `ae2cbbee`, merge `60f62799` | Initial dual-lens review found 10 issues; fix re-review left one Linux execute-bit gap; final narrow re-review PASS | Avalonia 12.1.1 launcher remains thin over one BCL-only Core orchestrator. Windows/WSL Launcher.Core 162/162 and Launcher 17/17. Native `linux-x64` publish evaluates self-contained + single-file, runs without a discoverable runtime, and CI verifies executable launcher/App/Headless artifacts. LA9/LA10 bodies and LA11 visual/accessibility confirmation remain intentionally later. |
|
||||||
|
| LA5 | **DONE + MERGED 2026-08-14** | `95f4be94`, `fbe9c8a2`, `f820eb25`, merge `5535d0ad` | Initial review found 5 issues; first narrow re-review found 4 ownership/race gaps; final narrow re-review PASS | Both hosts share exact absent/null=`all`, `[]`=`none` allow-listing; transactional scoped UI/entity/selection rollback precedes unload; graphical/headless status and teardown ordering match; headless replay is exact-once under Runtime's borrowed membership lease. Branch complete suite 13,679+4 skip; portable WSL closure green. |
|
||||||
|
| LA6 | **DONE + MERGED 2026-08-14** | `41b15efd`, `259f0e5a`, merge `2bb8ccb6` | Dual-lens/CH regression review found one Headless wire-parity gap; narrow re-review PASS | Runtime owns the sole parser/router/catalog and shared four-route live binding. Both hosts run generation-scoped login commands after world entry with strict monotonic delay and nonterminal v1 failure status. Headless permit/chat/notell semantics match App. Branch complete suite 13,787+4 skip; WSL Runtime 1,662, Headless 165, Launcher.Core 167, UI/chat 922. |
|
||||||
|
| LA7 | **DONE + MERGED 2026-08-14** | LA7a `6a32f375`, `4338b1c1`, `0c8643a7`, merge `fa2de1c4`; LA7b `0e82cbf7`, `1b9e7e41`, `ff406562`, merge `7691cf75` | LA7a retail-lens PASS; LA7b review found 4 issues, first narrow pass left one restore/delete interleave, final narrow re-review PASS; AD-97 filed | Runtime owns the sole generation-scoped pre-world selection graph. Exact retail roster/grey/button/delete/restore behavior and queue routing are preserved; `NumErrors` is a sentinel, paused selection retains reliable transport sweeping, silent restore cannot block, and App has no mirror. Windows Runtime 1,653, Core.Net 958, App 5,042+3 skip; WSL Runtime/Core.Net green. |
|
||||||
|
| LA8 | **DONE + MERGED 2026-08-14** | `6cfab727`, `aeac874d`, `1dd5706e`, merge `fe63ce18` | Initial retail/architecture review found 4 issues; first narrow re-review left 2 retry-transaction/order gaps; final narrow re-review PASS | Installed DAT enum table 5 proves `0x10000005 -> 0x21000004`, root `0x1000039A`, exact flat list/buttons/templates/dialog assets, and no viewport. Runtime remains the only selection owner; row sizing, modal priority/retry, restore ordering, reset/disposal, and explicit live-DAT skip/probe are covered. Branch full suite 13,796+5 skip; LA11 owns physical visual/live-ACE acceptance. |
|
||||||
|
| LA9 | **DONE + MERGED 2026-08-14** | `ff6ebb6a`, `3f688951`, `208a70ac`, merge `2198a0cc` | Initial integrity review found 5 issues; narrow re-review left one orphan-child publication race; final narrow re-review PASS | First-run installer validates four DATs, consumes strict v1 Bake JSONL, preserves/reverifies SHA+size+tool-version records, and co-publishes self-contained launcher+Bake. Cross-process install/publish locks plus durable nonce prevent post-recovery mutation across real parent-only hard kills on Windows/Linux. Branch full suite 13,799+4 skip; real retail-DAT bake remains LA11. |
|
||||||
|
| LA10 | **DONE + MERGED 2026-08-14** | `2d2a5b50`, `1955ca8a`, `09d84387`, merge `da4fb3de` | Initial architecture/security review found 10 crash, trust, integrity, cleanup, and lifecycle issues; first narrow re-review left one rollback-source P1; final narrow re-review PASS | Production feeds and redirects are HTTPS-only, fixture loopback trust is explicit, downloads and archives are bounded and verified, version activation and rollback are atomic, active sessions hold the cross-process update lease, and schema-v3 self-update recovery verifies every prior/replacement file before apply, rollback, or restart. Real Windows/Linux process tests cover kill boundaries, staging races, lease deferral, corrupt backups, junctions/symlinks, and fail-closed recovery. Branch gates: Core 302/302 and Launcher 29/29 on Windows/WSL, full Release 13,945+4 skip, win/linux self-contained publishes. Integrated LA0–LA10 gate: 13,972+5 skip. |
|
||||||
|
| LA11 | **AUTOMATED CLOSEOUT REVIEW-CLOSED + MERGED 2026-08-15 — USER GATE PENDING** | `f881e5b4`, `134edabe`, `accd01a0`, `9f9c1167`, merge `d39f3098` | Initial dual-lens review found 7 startup/evidence/safety issues; first narrow re-review left 2 PID-reuse/ZIP-mode gaps; final narrow re-review PASS | Strict isolated roots and process-local feed override compose one exact launcher path graph. Windows targeted CTRL_BREAK is group-isolated and preserves stdin; exact-PID/start-identity status validation, credential-value scanning, deterministic Unix-mode A/B fixtures, Windows/native-Linux helper safety, and the exact A–I operator script are implemented. Clean branch preflight passed 32/32 with 13,985 tests + 4 skips. Integrated clean-head preflight at `a22f5411` passed 32/32 with 14,012 tests + 5 skips and report SHA-256 `49f225bc6043b9256f17b7bf0f29df919c894b8355633077751fd279756470df`. No connected/UI/real-DAT row has run; campaign shipment and #397 closure remain pending the user gate. |
|
||||||
294
docs/plans/2026-08-15-character-creation-campaign.md
Normal file
294
docs/plans/2026-08-15-character-creation-campaign.md
Normal file
File diff suppressed because one or more lines are too long
284
docs/research/2026-08-14-campaign-la-handoff.md
Normal file
284
docs/research/2026-08-14-campaign-la-handoff.md
Normal 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 3–4 agents in parallel INCLUDING children; subagents never spawn
|
||||||
|
subagents. Every implementer prompt carries: spec+plan paths, files to read
|
||||||
|
first, the pinned contract text if relevant, acceptance criteria, commit
|
||||||
|
style, and a base-commit verification as its first action.
|
||||||
|
- One implementer per worktree; that agent owns the worktree's git index.
|
||||||
|
- `dotnet build` + `dotnet test` green before a slice is DONE; ≥1 commit per
|
||||||
|
slice tagged `Campaign LA`; every retail deviation adds its
|
||||||
|
`docs/architecture/retail-divergence-register.md` row in the same commit;
|
||||||
|
**no workarounds without explicit user approval**.
|
||||||
|
- Linux: every slice touching Launcher.Core/Headless/Runtime/Bake/Platform runs
|
||||||
|
its test projects under WSL or native Ubuntu before it is DONE.
|
||||||
|
- The ONLY stop-and-wait is a user connected/visual gate. Everything else is
|
||||||
|
Claude's call — never present the user a work-order menu.
|
||||||
|
- Keep the plan ledger, `docs/plans/2026-04-11-roadmap.md`, the CLAUDE.md
|
||||||
|
Current-state pointer, and `claude-memory/` current as slices land.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Kickoff prompt for the new session
|
||||||
|
|
||||||
|
Paste this as the FIRST message of the new session (it names the three
|
||||||
|
resumable work items explicitly), then set the goal in §7.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Resume Campaign LA (the acdream launcher). Work in
|
||||||
|
C:\Users\erikn\source\repos\acdream\.claude\worktrees\acdream-launcher-credentials-4d2f7c
|
||||||
|
on branch claude/acdream-launcher-credentials-4d2f7c. Read
|
||||||
|
docs/research/2026-08-14-campaign-la-handoff.md first, then the ledger in
|
||||||
|
docs/plans/2026-08-14-launcher-campaign.md.
|
||||||
|
|
||||||
|
Three items are waiting, all recoverable from git — two are partial work from
|
||||||
|
agents that were stopped mid-task for token budget, and their build/test state
|
||||||
|
is UNVERIFIED:
|
||||||
|
|
||||||
|
1. LA1 fix round — WIP commit 75a6724d on this branch. An agent had completed
|
||||||
|
findings F1 (best-effort SessionStatusWriter that never throws into the
|
||||||
|
login/teardown transactions and creates its parent directory), F2 (App
|
||||||
|
reader tolerates process.paths and explicitly refuses mode:"probe"), and F5
|
||||||
|
(--session-config argument hardening). It was stopped just as it started F4.
|
||||||
|
Finish: F4 production-shape tests/Fixtures/campaign-la/session-config-shared-fixture.json
|
||||||
|
(add process.content, switch the credential to standardInput/session, assert
|
||||||
|
values in BOTH host suites), F3 (reconnect emits disconnected before the
|
||||||
|
second connected; record the mid-play wire-drop limitation in the plan's
|
||||||
|
status-stream section), F6 (exited idempotency + distinct reason strings),
|
||||||
|
F7 (make the Runtime redaction test structural — assert the exact serialized
|
||||||
|
property set per event kind), F8 (platform-guard file-set test + fix the
|
||||||
|
overstating isLinux comment), and optionally redact LivePass from
|
||||||
|
RuntimeOptions.PrintMembers. Then build, run Runtime/App/Headless Release
|
||||||
|
suites plus WSL for Runtime/Headless, and dispatch the narrow re-review.
|
||||||
|
|
||||||
|
2. LA2 — WIP commit c6019424 in worktree
|
||||||
|
C:\Users\erikn\source\repos\acdream\.claude\worktrees\acdream-la2 (branch
|
||||||
|
campaign-la2). An agent had implemented the probe flag through
|
||||||
|
LiveSessionConnectOptions, the StartCore short-circuit before selection, the
|
||||||
|
mode field with the JsonRequired-to-semantic-validation move, and the host
|
||||||
|
exit-code mapping, with 34 tests green including 3 probe tests. It was
|
||||||
|
stopped before writing the idle-policy unit tests. Finish those, run the
|
||||||
|
Runtime+Headless Release suites on Windows and WSL, then dispatch the Opus
|
||||||
|
dual-lens review.
|
||||||
|
|
||||||
|
3. LA3 — COMPLETE at 26feba81 in worktree
|
||||||
|
C:\Users\erikn\source\repos\acdream\.claude\worktrees\acdream-la3 (branch
|
||||||
|
campaign-la3). All 12 review findings fixed, 94/94 Windows and WSL. It needs
|
||||||
|
only a narrow Opus re-review of 26feba81 against the finding list in §3 of
|
||||||
|
the handoff, then merge into the campaign branch.
|
||||||
|
|
||||||
|
At the LA1+LA3 merge, do not lose the two owed items: the cross-assembly
|
||||||
|
contract test (feed a Launcher.Core composer document to BOTH host loaders) and
|
||||||
|
adding tests/AcDream.Launcher.Core.Tests to the Linux CI lane in
|
||||||
|
.github/workflows/headless-portability.yml.
|
||||||
|
|
||||||
|
After those land, continue the ledger: LA4 (Avalonia UI), LA5 (plugin hosting),
|
||||||
|
LA6 (login commands), LA7b (char-select state+flow), LA8 (authored screen), LA9
|
||||||
|
(installer), LA10 (updater), LA11 (closeout).
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. The goal to set
|
||||||
|
|
||||||
|
Set this with `/goal` in the new session (it is the same directive this session
|
||||||
|
ran under, refreshed for the current state):
|
||||||
|
|
||||||
|
```text
|
||||||
|
GOAL: Ship Campaign LA — the acdream launcher/installer/updater + retail character-select screen.
|
||||||
|
|
||||||
|
Work in C:\Users\erikn\source\repos\acdream\.claude\worktrees\acdream-launcher-credentials-4d2f7c
|
||||||
|
(branch claude/acdream-launcher-credentials-4d2f7c). Start at
|
||||||
|
docs/research/2026-08-14-campaign-la-handoff.md, then the ledger in
|
||||||
|
docs/plans/2026-08-14-launcher-campaign.md. Finish the three waiting items
|
||||||
|
first — LA1 fix round (WIP 75a6724d, unverified), LA2 (WIP c6019424 on
|
||||||
|
campaign-la2, unverified), LA3 (complete at 26feba81 on campaign-la3, owes a
|
||||||
|
narrow re-review then merge) — then continue slice by slice.
|
||||||
|
|
||||||
|
Process, per slice:
|
||||||
|
1. Fable plans/sequences/integrates — never present work-order menus; pick and announce.
|
||||||
|
2. Sonnet subagents implement bounded slices. Each prompt carries spec+plan paths, the
|
||||||
|
exact files to read first, the pinned contract text when relevant, acceptance criteria
|
||||||
|
(build+test green), commit style, and a base-commit verification as first action.
|
||||||
|
Subagents may not spawn subagents. Hard cap 3-4 agents in parallel including children.
|
||||||
|
One implementer per worktree; that agent owns the index — the orchestrator makes no git
|
||||||
|
state changes in a worktree while an agent is live in it.
|
||||||
|
3. Every slice boundary gets an Opus dual-lens review: (a) architectural — ownership,
|
||||||
|
layering, dependency-guard integrity, seams; (b) retail fidelity against
|
||||||
|
docs/research/named-retail/ wherever the slice touches retail behavior. Fix findings,
|
||||||
|
then a narrow re-review of the fixes.
|
||||||
|
4. dotnet build + dotnet test green before any slice is declared done; Linux (WSL or
|
||||||
|
native Ubuntu) runs for every slice touching Launcher.Core/Headless/Runtime/Bake/
|
||||||
|
Platform. One commit per slice minimum, tagged "Campaign LA". Any retail deviation adds
|
||||||
|
its divergence-register row in the same commit. No workarounds without explicit approval.
|
||||||
|
5. As slices land: update the plan ledger, docs/plans/2026-04-11-roadmap.md, the CLAUDE.md
|
||||||
|
Current state pointer, and claude-memory/.
|
||||||
|
6. The only stop-and-wait is a user connected/visual gate (launch modes vs local ACE,
|
||||||
|
character-probe round-trip, char-select visual matrix, first-run wizard, update swap).
|
||||||
|
When a slice reaches one: write the exact test script under docs/research/, announce the
|
||||||
|
gate, and keep driving any slices not blocked behind it.
|
||||||
|
|
||||||
|
DONE = all slices code-complete, automated gates green, dual reviews closed, and the
|
||||||
|
connected-gate checklist delivered to the user in one script document.
|
||||||
|
```
|
||||||
631
docs/research/2026-08-14-campaign-la-test-script.md
Normal file
631
docs/research/2026-08-14-campaign-la-test-script.md
Normal 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 A–I are deliberately manual and serial:
|
||||||
|
they use real retail DATs, a local ACE server, user-entered credentials, and
|
||||||
|
visual judgment that automation cannot supply.
|
||||||
|
|
||||||
|
## 1. Safety boundary and required inputs
|
||||||
|
|
||||||
|
Use placeholders throughout; never paste a password into a terminal, this
|
||||||
|
document, a screenshot, or a gate report.
|
||||||
|
|
||||||
|
- `<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 A–H. Ubuntu x64 with PowerShell 7 and a Linux
|
||||||
|
desktop/WSLg is required for row I. The Avalonia launcher is supported on
|
||||||
|
Linux; `gui` and `guiSelect` **client** actions must remain disabled with the
|
||||||
|
Modern Runtime Slice-L explanation.
|
||||||
|
|
||||||
|
Close every unrelated `AcDream.App`, `acdream-headless`, and acdream launcher
|
||||||
|
before starting. Do not run another acdream gate in parallel. All generated
|
||||||
|
files must stay below one new gate directory; the canonical `%APPDATA%`,
|
||||||
|
`%LOCALAPPDATA%`, and XDG acdream roots are out of scope.
|
||||||
|
|
||||||
|
## 2. Automated preflight — no UI, connection, credential, or bake
|
||||||
|
|
||||||
|
In PowerShell 7 on Windows:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$Repo = [IO.Path]::GetFullPath('<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 | 5–15 min |
|
||||||
|
| Windows | complete Release solution test, serial | exit 0; ordinary known skips only | 20–60 min |
|
||||||
|
| Windows | focused Launcher.Core update tests and launcher update/startup-option tests | exit 0 | 1–4 min |
|
||||||
|
| Windows | canonical portable project build/test closure plus Headless `--help` and empty-config `validate` from `headless-portability.yml` | every project/CLI row exits 0 | 10–25 min |
|
||||||
|
| Windows | self-contained single-file launcher publish for `win-x64` and `linux-x64` | launcher + bake roots present, no root DLL fallback | 3–10 min |
|
||||||
|
| Windows | native launcher `--verify-publish` and bake `--help` with bogus `DOTNET_ROOT*` | both exit 0 | <1 min |
|
||||||
|
| Ubuntu/WSL | run the same helper natively from the Linux path to the worktree | Linux RID report and every row exit 0 | 35–90 min |
|
||||||
|
|
||||||
|
`report.json` records the tested HEAD/dirty state, OS/RID, exact commands,
|
||||||
|
durations, exits, redacted logs, and SHA-256/size inventory. A normal preflight
|
||||||
|
plans 32 rows, including the connection-free PID/status/redaction and script-
|
||||||
|
safety contract suites. `-AllowedOutputRoot` must be a fresh, explicit
|
||||||
|
`campaign-la-*` gate root (or the repository `logs` root), and output must be a
|
||||||
|
fresh, empty, non-reparse strict descendant; repository, home, source, payload,
|
||||||
|
nonempty, and arbitrary existing directories are rejected. The helper never
|
||||||
|
launches App or Headless in connected mode and never reads a credential. Every
|
||||||
|
child starts with all inherited `ACDREAM_*`
|
||||||
|
variables removed, so a developer shell cannot accidentally enable live,
|
||||||
|
installed-DAT, fixture-regeneration, or diagnostic gates. Only the optional
|
||||||
|
row below adds the two named DAT variables back for its three exact tests.
|
||||||
|
|
||||||
|
### Optional installed-DAT read-only row
|
||||||
|
|
||||||
|
This is not a bake and must not replace row A. Add the switches below only when
|
||||||
|
the DAT directory may be read by tests:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pwsh -NoProfile -File (Join-Path $Repo 'tools/run-campaign-la-preflight.ps1') `
|
||||||
|
-Repository $Repo `
|
||||||
|
-AllowedOutputRoot $Gate `
|
||||||
|
-OutputDirectory (Join-Path $Gate 'automated-preflight-with-dat') `
|
||||||
|
-IncludeInstalledDat `
|
||||||
|
-InstalledDatDirectory '<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 D–F. The validator enforces exact
|
||||||
|
v1 fields **and property order**, one session id, UTC monotonic timestamps,
|
||||||
|
mode-specific lifecycle order, exit code 0/reason, no unexpected plugin/login
|
||||||
|
command failure, exact terminal `disconnected.reason == stopped`, credential
|
||||||
|
redaction, and that exact captured process instance is gone. Independent exact
|
||||||
|
config-path correlation uses Windows CIM or Linux `/proc/*/cmdline`; it never
|
||||||
|
globally scans a process name, treats a different start identity on a reused PID
|
||||||
|
as a different process, and is unaffected by unrelated same-name processes or
|
||||||
|
Linux's 15-character names. The validator verifies owner-only profile access,
|
||||||
|
reads only password/secret fields in memory, recursively checks every allowed status
|
||||||
|
string (including command/error text), and reports only the forbidden-value
|
||||||
|
count and status hash—never credential content or a credential hash. Keep the
|
||||||
|
profile, raw `session.json`/`status.jsonl`, and raw process-capture sidecar
|
||||||
|
(which contains the absolute isolated path) local; never upload them.
|
||||||
|
|
||||||
|
## 5. Serial Windows user rows A–H
|
||||||
|
|
||||||
|
### A — isolated first run, real DAT bake, and release-A client baseline
|
||||||
|
|
||||||
|
1. Confirm the launcher opens First-run setup and all launch buttons are
|
||||||
|
unavailable. Save a redacted screenshot as `A-first-run-required.png`.
|
||||||
|
2. Enter `<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 30–180 minutes. Confirm every phase reaches **Completed** and
|
||||||
|
the status says `Client content installed and verified. Launch is enabled.`
|
||||||
|
4. Open **Check for updates**. Confirm available release A, click **Install
|
||||||
|
client**, and wait for `Client update installed and activated.` Do not stage
|
||||||
|
launcher A; the test launcher already has version A.
|
||||||
|
5. Confirm these exact isolated artifacts exist and no `.previous-install`
|
||||||
|
remains after success:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$RequiredA = @(
|
||||||
|
(Join-Path $WinData 'install.json'),
|
||||||
|
(Join-Path $WinData 'pak/acdream.pak'),
|
||||||
|
(Join-Path $WinData 'app/current.json'))
|
||||||
|
foreach ($Path in $RequiredA) {
|
||||||
|
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Missing $Path" }
|
||||||
|
}
|
||||||
|
$RequiredA | ForEach-Object {
|
||||||
|
$Item = Get-Item -LiteralPath $_
|
||||||
|
[ordered]@{
|
||||||
|
name = $Item.Name
|
||||||
|
size = $Item.Length
|
||||||
|
sha256 = (Get-FileHash -LiteralPath $_ -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||||
|
}
|
||||||
|
} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Evidence 'A-install-hashes.json')
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not copy `install.json` into shared evidence because it records the local DAT
|
||||||
|
path. Expected time: 45–200 minutes including bake.
|
||||||
|
|
||||||
|
### B — server/account CRUD entirely through the UI
|
||||||
|
|
||||||
|
1. Add `<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: 10–15 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 1–3 immediately, producing a different session id,
|
||||||
|
`C-probe-2-status.validation.json`, and `C-probe-2-ace-cleared.png`.
|
||||||
|
5. Confirm Refresh is re-enabled and no `acdream-headless` process remains.
|
||||||
|
|
||||||
|
Expected time: 5–10 minutes. A stale ACE account or timeout is a gate failure;
|
||||||
|
do not wait three minutes and call the next attempt a pass.
|
||||||
|
|
||||||
|
### D — `guiSelect`, retail character screen, plugin, and login command
|
||||||
|
|
||||||
|
1. Select one non-disposable roster character. Set its mode to `guiSelect`,
|
||||||
|
Plugins to exactly `acdream.smoke`, and its one login command to
|
||||||
|
`/tell <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: 5–10 minutes.
|
||||||
|
|
||||||
|
### E — direct `gui`, plugin, and login command
|
||||||
|
|
||||||
|
1. Change the same character to `gui`, retain `acdream.smoke`, and change the
|
||||||
|
command nonce to `LA11-E-<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: 5–10 minutes.
|
||||||
|
|
||||||
|
### F — headless, plugin/login command, and connected #397 acceptance
|
||||||
|
|
||||||
|
1. Change the same character to `headless`, retain `acdream.smoke`, and use
|
||||||
|
`LA11-F-<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: 5–10 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: 5–10 minutes.
|
||||||
|
|
||||||
|
### H — local A→B client update, active-session refusal, rollback, self-update
|
||||||
|
|
||||||
|
1. Record release A's `app/current.json`. Start one headless session and wait
|
||||||
|
for `enteredWorld`.
|
||||||
|
2. Switch the fixture atomically to B:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pwsh -NoProfile -File (Join-Path $Fixture 'set-active-release.ps1') `
|
||||||
|
-Release B -Root $Fixture
|
||||||
|
if ((Invoke-RestMethod -Uri $ManifestUri).version -cne $VersionB) {
|
||||||
|
throw 'Stop: fixture did not switch to B.'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Open **Check for updates** and **Check again**. While the session is active,
|
||||||
|
confirm Install client, Rollback client, and Stage launcher are disabled or
|
||||||
|
refuse without changing `app/current.json`. Save `H-active-refusal.png`.
|
||||||
|
4. Stop the headless session and validate its graceful status. Install client
|
||||||
|
B. Confirm `app/current.json` names B, A is previous, all installed-file
|
||||||
|
hashes verify, and new sessions resolve from the B directory.
|
||||||
|
5. Click **Rollback client**. Confirm A becomes current and B becomes previous.
|
||||||
|
Check again and install B once more, leaving B current. Save sanitized copies
|
||||||
|
of the three pointer states as `H-pointer-a.json`, `H-pointer-b.json`, and
|
||||||
|
`H-pointer-rollback-a.json`; they contain no credentials.
|
||||||
|
6. Click **Stage launcher**. Confirm restart is required, then close the
|
||||||
|
launcher normally. The copied helper must apply B and restart the launcher
|
||||||
|
with the same config/data/cache/feed suffix.
|
||||||
|
7. Confirm profiles, install record, and update state still come from the
|
||||||
|
isolated roots; `campaign-la-fixture-release.txt` beside the relaunched
|
||||||
|
executable says `release=B`; `launcher-update/pending.json` is gone; and no
|
||||||
|
transaction backup remains. Check again and confirm launcher B is current.
|
||||||
|
Save `H-self-update-confirmed.png` and a hash-only post-state inventory.
|
||||||
|
|
||||||
|
Never edit a manifest to force this row and never point the launcher at a
|
||||||
|
non-loopback HTTP endpoint. Expected time: 15–30 minutes.
|
||||||
|
|
||||||
|
## 6. Row I — native Ubuntu/WSL launcher, XDG-shaped isolated roots
|
||||||
|
|
||||||
|
Stop the Windows launcher and fixture server only after every Windows session
|
||||||
|
is terminal:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
if (-not $FixtureServer.HasExited) {
|
||||||
|
$FixtureServer.Kill()
|
||||||
|
$FixtureServer.WaitForExit()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In a native Ubuntu/WSL PowerShell 7 terminal, set Linux paths. The repository
|
||||||
|
and fixture may be read from a mounted Windows path, but roots must live on the
|
||||||
|
Linux filesystem. Run the generated server natively so its `127.0.0.1` URLs
|
||||||
|
cannot escape the Linux environment:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$RepoLinux = [IO.Path]::GetFullPath('<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: 60–220 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
|
||||||
|
A–I as `pass`, `fail`, or `notApplicable`, a short redacted note per row, and
|
||||||
|
the user's overall verdict. Row I is not applicable only when no native
|
||||||
|
Ubuntu/WSL desktop is available; it blocks Campaign LA shipping under the
|
||||||
|
current Linux requirement, so it cannot be silently omitted.
|
||||||
|
|
||||||
|
Cleanup is graceful-first and serial:
|
||||||
|
|
||||||
|
1. Restore `<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: 3–7 hours, primarily the two real DAT bakes and full serial
|
||||||
|
test suites. A failure stops the current row; restore/stop/collect evidence,
|
||||||
|
then diagnose before advancing. Do not mark LA11, Campaign LA, or #397 shipped
|
||||||
|
until the user accepts the complete applicable matrix.
|
||||||
109
docs/research/2026-08-14-la7b-character-selection-runtime.md
Normal file
109
docs/research/2026-08-14-la7b-character-selection-runtime.md
Normal 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.
|
||||||
120
docs/research/2026-08-14-la8-character-management-ui.md
Normal file
120
docs/research/2026-08-14-la8-character-management-ui.md
Normal 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.
|
||||||
1166
docs/research/2026-08-16-campaign-cc-gate-round1-findings.md
Normal file
1166
docs/research/2026-08-16-campaign-cc-gate-round1-findings.md
Normal file
File diff suppressed because it is too large
Load diff
395
docs/research/2026-08-16-campaign-cc-test-script.md
Normal file
395
docs/research/2026-08-16-campaign-cc-test-script.md
Normal 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.
|
||||||
389
docs/superpowers/specs/2026-08-14-launcher-campaign-design.md
Normal file
389
docs/superpowers/specs/2026-08-14-launcher-campaign-design.md
Normal 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.
|
||||||
|
|
@ -12,9 +12,10 @@ Linux gameplay or renderer fork.
|
||||||
|
|
||||||
Canonical seams:
|
Canonical seams:
|
||||||
|
|
||||||
- `AcDream.Runtime.Platform.ApplicationPathSet` owns XDG/Windows config, data,
|
- `AcDream.Platform.ApplicationPathSet` (moved out of Runtime by Campaign LA
|
||||||
cache, plugin, screenshot, and diagnostic paths for graphical and headless
|
LA0, 2026-08-14) owns XDG/Windows config, data, cache, plugin, screenshot,
|
||||||
hosts.
|
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,
|
- `AcDream.App.Platform.GraphicalHostPlatformServices` owns one startup OS,
|
||||||
architecture, RID, native-dependency manifest, path set, and pacing factory.
|
architecture, RID, native-dependency manifest, path set, and pacing factory.
|
||||||
- `PlatformFramePacingWaiterFactory` selects the existing Windows
|
- `PlatformFramePacingWaiterFactory` selects the existing Windows
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,11 @@
|
||||||
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
|
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
|
||||||
<ProjectReference Include="..\AcDream.Core.Net\AcDream.Core.Net.csproj" />
|
<ProjectReference Include="..\AcDream.Core.Net\AcDream.Core.Net.csproj" />
|
||||||
<ProjectReference Include="..\AcDream.Content\AcDream.Content.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" />
|
<ProjectReference Include="..\AcDream.UI.Abstractions\AcDream.UI.Abstractions.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,11 @@ using AcDream.Core.Physics;
|
||||||
using AcDream.Core.Rendering;
|
using AcDream.Core.Rendering;
|
||||||
using AcDream.Core.Spells;
|
using AcDream.Core.Spells;
|
||||||
using AcDream.Core.Vfx;
|
using AcDream.Core.Vfx;
|
||||||
|
using AcDream.Core.CharGen;
|
||||||
using AcDream.Runtime;
|
using AcDream.Runtime;
|
||||||
using AcDream.Runtime.Gameplay;
|
using AcDream.Runtime.Gameplay;
|
||||||
using AcDream.Runtime.Physics;
|
using AcDream.Runtime.Physics;
|
||||||
|
using AcDream.Runtime.Session;
|
||||||
using DatReaderWriter;
|
using DatReaderWriter;
|
||||||
using Silk.NET.Input;
|
using Silk.NET.Input;
|
||||||
|
|
||||||
|
|
@ -63,6 +65,14 @@ internal sealed record ContentEffectsAudioDependencies(
|
||||||
Action<string> Error)
|
Action<string> Error)
|
||||||
{
|
{
|
||||||
public RuntimeCharacterState Character => Runtime.CharacterOwner;
|
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
|
internal interface IGameWindowContentEffectsAudioPublication
|
||||||
|
|
@ -96,6 +106,13 @@ internal interface IContentEffectsAudioCompositionFactory
|
||||||
RuntimeCharacterState character,
|
RuntimeCharacterState character,
|
||||||
MagicCatalog catalog);
|
MagicCatalog catalog);
|
||||||
int GetSpellCount(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(
|
IAnimationLoader CreateAnimationLoader(
|
||||||
IDatReaderWriter dats,
|
IDatReaderWriter dats,
|
||||||
long maximumEstimatedBytes,
|
long maximumEstimatedBytes,
|
||||||
|
|
@ -166,6 +183,12 @@ internal sealed class RetailContentEffectsAudioCompositionFactory
|
||||||
|
|
||||||
public int GetSpellCount(MagicCatalog catalog) => catalog.SpellTable.Count;
|
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(
|
public IAnimationLoader CreateAnimationLoader(
|
||||||
IDatReaderWriter dats,
|
IDatReaderWriter dats,
|
||||||
long maximumEstimatedBytes,
|
long maximumEstimatedBytes,
|
||||||
|
|
@ -270,6 +293,7 @@ internal enum ContentEffectsAudioCompositionPoint
|
||||||
PreparedAssetSourcePublished,
|
PreparedAssetSourcePublished,
|
||||||
MagicCatalogPublished,
|
MagicCatalogPublished,
|
||||||
SpellMetadataInstalled,
|
SpellMetadataInstalled,
|
||||||
|
ChargenOptionsInstalled,
|
||||||
AnimationLoaderPublished,
|
AnimationLoaderPublished,
|
||||||
CollisionBuilderPublished,
|
CollisionBuilderPublished,
|
||||||
EmitterRegistryPublished,
|
EmitterRegistryPublished,
|
||||||
|
|
@ -362,6 +386,12 @@ internal sealed class ContentEffectsAudioCompositionPhase :
|
||||||
$"spells: loaded {_factory.GetSpellCount(magic)} entries from portal.dat");
|
$"spells: loaded {_factory.GetSpellCount(magic)} entries from portal.dat");
|
||||||
Fault(ContentEffectsAudioCompositionPoint.SpellMetadataInstalled);
|
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(
|
IAnimationLoader animations = _factory.CreateAnimationLoader(
|
||||||
dats,
|
dats,
|
||||||
_dependencies.ResidencyBudgets.AnimationBytes,
|
_dependencies.ResidencyBudgets.AnimationBytes,
|
||||||
|
|
|
||||||
|
|
@ -539,7 +539,9 @@ internal sealed class FrameRootCompositionPhase
|
||||||
renderFrameResources,
|
renderFrameResources,
|
||||||
new PrivateEntityViewportFrameGroup(
|
new PrivateEntityViewportFrameGroup(
|
||||||
live.PaperdollPresenter,
|
live.PaperdollPresenter,
|
||||||
live.CreatureAppraisalPresenter),
|
live.CreatureAppraisalPresenter,
|
||||||
|
live.ChargenPreviewController,
|
||||||
|
live.SummaryPreviewController),
|
||||||
retainedGameplayUi,
|
retainedGameplayUi,
|
||||||
// The ImGui developer-tools frontend was removed at Campaign V
|
// The ImGui developer-tools frontend was removed at Campaign V
|
||||||
// slice V11; this optional hook is unbound until a follow-up
|
// slice V11; this optional hook is unbound until a follow-up
|
||||||
|
|
@ -581,7 +583,7 @@ internal sealed class FrameRootCompositionPhase
|
||||||
var liveFrameCoordinator = new RetailLiveFrameCoordinator(
|
var liveFrameCoordinator = new RetailLiveFrameCoordinator(
|
||||||
session.LiveObjectFrame,
|
session.LiveObjectFrame,
|
||||||
live.WorldState,
|
live.WorldState,
|
||||||
session.LiveSession,
|
session.SessionHost,
|
||||||
session.LocalPlayerFrame,
|
session.LocalPlayerFrame,
|
||||||
session.LiveSpatialReconciler,
|
session.LiveSpatialReconciler,
|
||||||
live.WorldAvailability,
|
live.WorldAvailability,
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,12 @@ using AcDream.Core.Selection;
|
||||||
using AcDream.Core.Spells;
|
using AcDream.Core.Spells;
|
||||||
using AcDream.Runtime;
|
using AcDream.Runtime;
|
||||||
using AcDream.Runtime.Gameplay;
|
using AcDream.Runtime.Gameplay;
|
||||||
|
using AcDream.Runtime.Session;
|
||||||
using AcDream.UI.Abstractions.Input;
|
using AcDream.UI.Abstractions.Input;
|
||||||
using AcDream.UI.Abstractions.Panels.Chat;
|
using AcDream.UI.Abstractions.Panels.Chat;
|
||||||
using AcDream.UI.Abstractions.Panels.Vitals;
|
using AcDream.UI.Abstractions.Panels.Vitals;
|
||||||
using DatReaderWriter;
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.DBObjs;
|
||||||
using Silk.NET.Input;
|
using Silk.NET.Input;
|
||||||
using Silk.NET.Windowing;
|
using Silk.NET.Windowing;
|
||||||
|
|
||||||
|
|
@ -483,6 +485,12 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
||||||
d.DebugFont,
|
d.DebugFont,
|
||||||
d.HostQuiescence));
|
d.HostQuiescence));
|
||||||
checkpoint(InteractionRetainedUiCompositionPoint.UiHostAcquired);
|
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);
|
inputCapture = d.RetainedInputCapture.Bind(host.Root);
|
||||||
checkpoint(InteractionRetainedUiCompositionPoint.InputCaptureBound);
|
checkpoint(InteractionRetainedUiCompositionPoint.InputCaptureBound);
|
||||||
// D7 Group-C re-point (Campaign OP OP4, 2026-08-11): server
|
// D7 Group-C re-point (Campaign OP OP4, 2026-08-11): server
|
||||||
|
|
@ -636,6 +644,32 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
||||||
d.DebugFont,
|
d.DebugFont,
|
||||||
controls,
|
controls,
|
||||||
iconComposer);
|
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(
|
var bindings = new RetailUiRuntimeBindings(
|
||||||
Host: host,
|
Host: host,
|
||||||
Assets: assets,
|
Assets: assets,
|
||||||
|
|
@ -937,7 +971,64 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
||||||
(action, held) =>
|
(action, held) =>
|
||||||
d.InputDispatcher?.TrySetAutomationActionHeld(action, held) == true,
|
d.InputDispatcher?.TrySetAutomationActionHeld(action, held) == true,
|
||||||
late.Automation),
|
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 runtime = lease.Mount(
|
||||||
() => RetailUiRuntime.CreateUninitialized(bindings));
|
() => RetailUiRuntime.CreateUninitialized(bindings));
|
||||||
checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted);
|
checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ using AcDream.Core.Items;
|
||||||
using AcDream.Core.Net;
|
using AcDream.Core.Net;
|
||||||
using AcDream.Core.Net.Messages;
|
using AcDream.Core.Net.Messages;
|
||||||
using AcDream.Runtime;
|
using AcDream.Runtime;
|
||||||
|
using AcDream.Runtime.Session;
|
||||||
using AcDream.UI.Abstractions;
|
using AcDream.UI.Abstractions;
|
||||||
using Silk.NET.Windowing;
|
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(
|
public IDisposable Bind(
|
||||||
IGameRuntimeView view,
|
IGameRuntimeView view,
|
||||||
IGameRuntimeCommands commands)
|
IGameRuntimeCommands commands)
|
||||||
|
|
@ -118,6 +150,119 @@ internal sealed class DeferredGameRuntimeStateCommands
|
||||||
generation,
|
generation,
|
||||||
new RuntimeAdvancementCommand(kind, statId, cost)));
|
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 ─────────────────
|
// ── Campaign FA slice FA4: fellowship page commands ─────────────────
|
||||||
// Same "capture view+commands under one generation" shape as every
|
// Same "capture view+commands under one generation" shape as every
|
||||||
// method above — a displaced session (reconnect mid-click) can never
|
// method above — a displaced session (reconnect mid-click) can never
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,18 @@ internal sealed record LivePresentationResult(
|
||||||
PaperdollFramePresenter? PaperdollPresenter,
|
PaperdollFramePresenter? PaperdollPresenter,
|
||||||
CreatureAppraisalViewportRenderer? CreatureAppraisalRenderer,
|
CreatureAppraisalViewportRenderer? CreatureAppraisalRenderer,
|
||||||
CreatureAppraisalFramePresenter? CreatureAppraisalPresenter,
|
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,
|
WbFrustum EnvCellFrustum,
|
||||||
EnvCellRenderer? EnvCellRenderer,
|
EnvCellRenderer? EnvCellRenderer,
|
||||||
LandblockPresentationPipeline LandblockPipeline,
|
LandblockPresentationPipeline LandblockPipeline,
|
||||||
|
|
@ -985,6 +997,235 @@ internal sealed class LivePresentationCompositionPhase
|
||||||
new RetailCreatureAppraisalCloneFactory(
|
new RetailCreatureAppraisalCloneFactory(
|
||||||
new LiveCreatureAppraisalEntityLookup(liveEntities)));
|
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);
|
Fault(LivePresentationCompositionPoint.PrivateCreatureViewportsCreated);
|
||||||
|
|
||||||
var envCellFrustum = new WbFrustum();
|
var envCellFrustum = new WbFrustum();
|
||||||
|
|
@ -1291,6 +1532,10 @@ internal sealed class LivePresentationCompositionPhase
|
||||||
paperdollPresenter,
|
paperdollPresenter,
|
||||||
creatureAppraisalLease?.Resource,
|
creatureAppraisalLease?.Resource,
|
||||||
creatureAppraisalPresenter,
|
creatureAppraisalPresenter,
|
||||||
|
chargenPreviewLease?.Resource,
|
||||||
|
chargenPreviewController,
|
||||||
|
summaryPreviewLease?.Resource,
|
||||||
|
summaryPreviewController,
|
||||||
envCellFrustum,
|
envCellFrustum,
|
||||||
envCellLease.Resource,
|
envCellLease.Resource,
|
||||||
landblockPipeline,
|
landblockPipeline,
|
||||||
|
|
@ -1325,6 +1570,17 @@ internal sealed class LivePresentationCompositionPhase
|
||||||
retainedGameplayLease?.Transfer();
|
retainedGameplayLease?.Transfer();
|
||||||
paperdollLease?.Transfer();
|
paperdollLease?.Transfer();
|
||||||
creatureAppraisalLease?.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();
|
envCellLease.Transfer();
|
||||||
clipFrameLease.Transfer();
|
clipFrameLease.Transfer();
|
||||||
portalDepthLease.Transfer();
|
portalDepthLease.Transfer();
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,11 @@ internal sealed record SessionPlayerDependencies(
|
||||||
CombatAttackOperationsSlot CombatAttackOperations,
|
CombatAttackOperationsSlot CombatAttackOperations,
|
||||||
CombatFeedbackSlot CombatFeedback,
|
CombatFeedbackSlot CombatFeedback,
|
||||||
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
|
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;
|
public RuntimeActionState Actions => Runtime.ActionOwner;
|
||||||
|
|
||||||
|
|
@ -1124,7 +1128,11 @@ internal sealed class SessionPlayerCompositionPhase
|
||||||
acceptedPositionDrive,
|
acceptedPositionDrive,
|
||||||
remotePlacementDrive),
|
remotePlacementDrive),
|
||||||
liveSessionCommands,
|
liveSessionCommands,
|
||||||
d.Log);
|
d.Log,
|
||||||
|
d.StatusWriter,
|
||||||
|
d.Options.SessionId ?? "app",
|
||||||
|
d.Options.LoginCommands,
|
||||||
|
d.Options.LoginCommandDelayMs);
|
||||||
LiveSessionHost sessionHost = sessionRuntimeFactory.Create(
|
LiveSessionHost sessionHost = sessionRuntimeFactory.Create(
|
||||||
liveSession,
|
liveSession,
|
||||||
new LiveSessionConnectOptions(
|
new LiveSessionConnectOptions(
|
||||||
|
|
@ -1132,7 +1140,10 @@ internal sealed class SessionPlayerCompositionPhase
|
||||||
d.Options.LiveHost,
|
d.Options.LiveHost,
|
||||||
d.Options.LivePort,
|
d.Options.LivePort,
|
||||||
d.Options.LiveUser ?? string.Empty,
|
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);
|
Fault(SessionPlayerCompositionPoint.SessionHostCreated);
|
||||||
|
|
||||||
// The ImGui developer-tools debug toast sink was removed at Campaign V
|
// The ImGui developer-tools debug toast sink was removed at Campaign V
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using AcDream.Runtime;
|
using AcDream.Runtime;
|
||||||
|
using AcDream.Runtime.Session;
|
||||||
|
|
||||||
namespace AcDream.App.Composition;
|
namespace AcDream.App.Composition;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
175
src/AcDream.App/Configuration/SessionConfiguration.cs
Normal file
175
src/AcDream.App/Configuration/SessionConfiguration.cs
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
184
src/AcDream.App/Configuration/SessionConfigurationLoader.cs
Normal file
184
src/AcDream.App/Configuration/SessionConfigurationLoader.cs
Normal 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}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
156
src/AcDream.App/Credentials/AppCredentialResolver.cs
Normal file
156
src/AcDream.App/Credentials/AppCredentialResolver.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/AcDream.App/Credentials/AppCredentialSecret.cs
Normal file
65
src/AcDream.App/Credentials/AppCredentialSecret.cs
Normal 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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
global using AcDream.Runtime.Gameplay;
|
global using AcDream.Runtime.Gameplay;
|
||||||
global using AcDream.Runtime.Physics;
|
global using AcDream.Runtime.Physics;
|
||||||
|
global using AcDream.Runtime.Chat;
|
||||||
global using ILocalPlayerMotionSource =
|
global using ILocalPlayerMotionSource =
|
||||||
AcDream.Runtime.Gameplay.IRuntimeLocalPlayerMotionSource;
|
AcDream.Runtime.Gameplay.IRuntimeLocalPlayerMotionSource;
|
||||||
|
|
|
||||||
|
|
@ -12,5 +12,5 @@ internal interface ILiveWorldSessionSource
|
||||||
|
|
||||||
internal interface ILiveUiSessionTarget : ILiveInWorldSource, ILiveWorldSessionSource
|
internal interface ILiveUiSessionTarget : ILiveInWorldSource, ILiveWorldSessionSource
|
||||||
{
|
{
|
||||||
AcDream.UI.Abstractions.ICommandBus Commands { get; }
|
AcDream.Runtime.Chat.ICommandBus Commands { get; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
using AcDream.App.UI;
|
using AcDream.App.UI;
|
||||||
using AcDream.Core.Chat;
|
using AcDream.Core.Chat;
|
||||||
using AcDream.Core.Items;
|
using AcDream.Core.Items;
|
||||||
using AcDream.Core.Net.Messages;
|
|
||||||
using AcDream.Runtime.Gameplay;
|
using AcDream.Runtime.Gameplay;
|
||||||
using AcDream.Runtime.Session;
|
using AcDream.Runtime.Session;
|
||||||
using AcDream.UI.Abstractions;
|
|
||||||
|
|
||||||
namespace AcDream.App.Net;
|
namespace AcDream.App.Net;
|
||||||
|
|
||||||
|
|
@ -149,6 +147,7 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
|
||||||
{
|
{
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
private LiveCommandBus? _commands;
|
private LiveCommandBus? _commands;
|
||||||
|
private LiveChatCommandRoute? _chatCommands;
|
||||||
private ClientCommandController.Bindings? _clientCommands;
|
private ClientCommandController.Bindings? _clientCommands;
|
||||||
private int _state; // 0 = constructed, 1 = active, 2 = disposed
|
private int _state; // 0 = constructed, 1 = active, 2 = disposed
|
||||||
|
|
||||||
|
|
@ -170,19 +169,22 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
|
||||||
var commands = new LiveCommandBus();
|
var commands = new LiveCommandBus();
|
||||||
var clientCommands = new ClientCommandController(
|
var clientCommands = new ClientCommandController(
|
||||||
BuildGuardedClientCommands(bindings.ClientCommands));
|
BuildGuardedClientCommands(bindings.ClientCommands));
|
||||||
commands.Register<ExecuteClientCommandCmd>(clientCommands.Execute);
|
_chatCommands = new LiveChatCommandRoute(new LiveChatCommandBindings(
|
||||||
commands.Register<SendServerCommandCmd>(command =>
|
clientCommands.Execute,
|
||||||
{
|
bindings.Communication,
|
||||||
if (!string.IsNullOrEmpty(command.Text))
|
bindings.Chat,
|
||||||
SendIfActive(() => bindings.SendTalk(command.Text));
|
bindings.TurbineChat,
|
||||||
});
|
bindings.CharacterState,
|
||||||
commands.Register<SendChatCmd>(command => RouteChat(bindings, command));
|
bindings.PlayerGuid,
|
||||||
|
bindings.SendTalk,
|
||||||
|
bindings.SendTell,
|
||||||
|
bindings.SendChannel,
|
||||||
|
bindings.SendTurbineChat,
|
||||||
|
bindings.Log));
|
||||||
// Campaign CH slice CH4 (2026-08-09): the 22 unregistered
|
// Campaign CH slice CH4 (2026-08-09): the 22 unregistered
|
||||||
// ChannelSystem::GetChannelID fallback tags — bypasses
|
// ChannelSystem::GetChannelID fallback tags — bypasses
|
||||||
// ChatChannelKind/ChannelResolver entirely and sends the raw
|
// ChatChannelKind/ChannelResolver entirely and sends the raw
|
||||||
// legacy ChatChannel (0x0147) broadcast directly.
|
// legacy ChatChannel (0x0147) broadcast directly.
|
||||||
commands.Register<SendRawChannelCmd>(
|
|
||||||
command => SendIfActive(() => bindings.SendChannel(command.ChannelId, command.Text)));
|
|
||||||
commands.Register<AddShortcutRuntimeCmd>(
|
commands.Register<AddShortcutRuntimeCmd>(
|
||||||
command => SendIfActive(() => bindings.AddShortcut(command.Entry)));
|
command => SendIfActive(() => bindings.AddShortcut(command.Entry)));
|
||||||
commands.Register<RemoveShortcutRuntimeCmd>(
|
commands.Register<RemoveShortcutRuntimeCmd>(
|
||||||
|
|
@ -320,6 +322,7 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
|
||||||
{
|
{
|
||||||
if (_state == 2)
|
if (_state == 2)
|
||||||
throw new ObjectDisposedException(nameof(LiveSessionCommandRouter));
|
throw new ObjectDisposedException(nameof(LiveSessionCommandRouter));
|
||||||
|
_chatCommands?.Activate();
|
||||||
_state = 1;
|
_state = 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -329,202 +332,31 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
if (_state == 1)
|
if (_state == 1)
|
||||||
|
{
|
||||||
|
if (_chatCommands?.TryPublish(command) != true)
|
||||||
_commands?.Publish(command);
|
_commands?.Publish(command);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
LiveCommandBus? commands;
|
LiveCommandBus? commands;
|
||||||
|
LiveChatCommandRoute? chatCommands;
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
_state = 2;
|
_state = 2;
|
||||||
commands = _commands;
|
commands = _commands;
|
||||||
_commands = null;
|
_commands = null;
|
||||||
|
chatCommands = _chatCommands;
|
||||||
|
_chatCommands = null;
|
||||||
_clientCommands = null;
|
_clientCommands = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
chatCommands?.Dispose();
|
||||||
commands?.Clear();
|
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(
|
private ClientCommandController.Bindings BuildGuardedClientCommands(
|
||||||
ClientCommandController.Bindings source) => new(
|
ClientCommandController.Bindings source) => new(
|
||||||
TeleportToLifestone: () => InvokeClient(static b => b.TeleportToLifestone()),
|
TeleportToLifestone: () => InvokeClient(static b => b.TeleportToLifestone()),
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ using AcDream.Runtime;
|
||||||
using AcDream.Runtime.Entities;
|
using AcDream.Runtime.Entities;
|
||||||
using AcDream.Runtime.Gameplay;
|
using AcDream.Runtime.Gameplay;
|
||||||
using AcDream.Runtime.Session;
|
using AcDream.Runtime.Session;
|
||||||
|
using AcDream.Runtime.Chat;
|
||||||
using AcDream.UI.Abstractions.Panels.Chat;
|
using AcDream.UI.Abstractions.Panels.Chat;
|
||||||
using AcDream.UI.Abstractions.Panels.Vitals;
|
using AcDream.UI.Abstractions.Panels.Vitals;
|
||||||
using DatReaderWriter;
|
using DatReaderWriter;
|
||||||
|
|
@ -104,6 +105,11 @@ internal sealed class LiveSessionRuntimeFactory
|
||||||
private readonly LiveSessionCommandSurface _commands;
|
private readonly LiveSessionCommandSurface _commands;
|
||||||
private readonly Action<string> _log;
|
private readonly Action<string> _log;
|
||||||
private readonly LiveMovementStatsApplier _movementStats;
|
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(
|
public LiveSessionRuntimeFactory(
|
||||||
LiveSessionPlayerRuntime player,
|
LiveSessionPlayerRuntime player,
|
||||||
|
|
@ -112,7 +118,12 @@ internal sealed class LiveSessionRuntimeFactory
|
||||||
LiveSessionInteractionRuntime interaction,
|
LiveSessionInteractionRuntime interaction,
|
||||||
LiveSessionWorldRuntime world,
|
LiveSessionWorldRuntime world,
|
||||||
LiveSessionCommandSurface commands,
|
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));
|
_player = player ?? throw new ArgumentNullException(nameof(player));
|
||||||
_domain = domain ?? throw new ArgumentNullException(nameof(domain));
|
_domain = domain ?? throw new ArgumentNullException(nameof(domain));
|
||||||
|
|
@ -122,6 +133,18 @@ internal sealed class LiveSessionRuntimeFactory
|
||||||
_world = world ?? throw new ArgumentNullException(nameof(world));
|
_world = world ?? throw new ArgumentNullException(nameof(world));
|
||||||
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
|
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
|
||||||
_log = log ?? throw new ArgumentNullException(nameof(log));
|
_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
|
// C3c-F1: stat recomputes route through the Runtime movement owner's
|
||||||
// typed application seam; App keeps zero direct controller mutations.
|
// typed application seam; App keeps zero direct controller mutations.
|
||||||
_movementStats = new LiveMovementStatsApplier(
|
_movementStats = new LiveMovementStatsApplier(
|
||||||
|
|
@ -142,6 +165,17 @@ internal sealed class LiveSessionRuntimeFactory
|
||||||
LiveSessionResetPlan reset =
|
LiveSessionResetPlan reset =
|
||||||
LiveSessionResetManifest.Create(
|
LiveSessionResetManifest.Create(
|
||||||
CreateResetBindings(resetHost));
|
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(
|
return new LiveSessionHost(controller, new LiveSessionHostBindings(
|
||||||
Routing: new(
|
Routing: new(
|
||||||
CreateEventRouter,
|
CreateEventRouter,
|
||||||
|
|
@ -176,9 +210,31 @@ internal sealed class LiveSessionRuntimeFactory
|
||||||
$"connecting to {host}:{port} as {user}",
|
$"connecting to {host}:{port} as {user}",
|
||||||
chatType: 1),
|
chatType: 1),
|
||||||
Connected: () =>
|
Connected: () =>
|
||||||
|
{
|
||||||
_domain.Communication.Chat.OnSystemMessage(
|
_domain.Communication.Chat.OnSystemMessage(
|
||||||
"connected — character list received",
|
"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);
|
connectOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using AcDream.Core.CharGen;
|
||||||
using DatReaderWriter.DBObjs;
|
using DatReaderWriter.DBObjs;
|
||||||
using DatReaderWriter.Types;
|
using DatReaderWriter.Types;
|
||||||
|
|
||||||
|
|
@ -32,6 +33,58 @@ internal static class RetailSkillFormula
|
||||||
result = (uint)Math.Floor((double)numerator / divisor + 0.5d);
|
result = (uint)Math.Floor((double)numerator / divisor + 0.5d);
|
||||||
return true;
|
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 >= 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>
|
/// <summary>
|
||||||
|
|
@ -70,3 +123,54 @@ internal sealed class LiveSkillCreditResolver(SkillTable? skillTable)
|
||||||
: 0u;
|
: 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
using AcDream.App.Rendering;
|
using AcDream.App.Rendering;
|
||||||
using AcDream.Runtime.Platform;
|
using AcDream.Platform;
|
||||||
|
|
||||||
namespace AcDream.App.Platform;
|
namespace AcDream.App.Platform;
|
||||||
|
|
||||||
|
|
@ -10,6 +11,21 @@ internal enum GraphicalHostOperatingSystem
|
||||||
Linux,
|
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(
|
internal sealed record GraphicalNativeDependency(
|
||||||
string Feature,
|
string Feature,
|
||||||
string PublishedFileName);
|
string PublishedFileName);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
using AcDream.Runtime.Platform;
|
using AcDream.Platform;
|
||||||
|
|
||||||
namespace AcDream.App.Platform;
|
namespace AcDream.App.Platform;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ public sealed class AppPluginHost : IPluginHost
|
||||||
Ui = ui;
|
Ui = ui;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool HasUi => true;
|
||||||
public IPluginLogger Log { get; }
|
public IPluginLogger Log { get; }
|
||||||
public IGameState State { get; }
|
public IGameState State { get; }
|
||||||
public IEvents Events { get; }
|
public IEvents Events { get; }
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using AcDream.App.UI;
|
||||||
using AcDream.Plugin.Abstractions;
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
namespace AcDream.App.Plugins;
|
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
|
/// Program.cs before the GL window opens) until GameWindow drains them into the
|
||||||
/// UiHost tree after construction.
|
/// UiHost tree after construction.
|
||||||
/// </summary>
|
/// </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)
|
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()
|
public IReadOnlyList<Pending> Drain()
|
||||||
{
|
{
|
||||||
var copy = _pending.ToArray();
|
lock (_gate)
|
||||||
_pending.Clear();
|
{
|
||||||
return copy;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
99
src/AcDream.App/Plugins/GraphicalPluginSession.cs
Normal file
99
src/AcDream.App/Plugins/GraphicalPluginSession.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
using AcDream.App;
|
using AcDream.App;
|
||||||
|
using AcDream.App.Configuration;
|
||||||
|
using AcDream.App.Credentials;
|
||||||
using AcDream.App.Plugins;
|
using AcDream.App.Plugins;
|
||||||
using AcDream.App.Platform;
|
using AcDream.App.Platform;
|
||||||
using AcDream.App.Rendering;
|
using AcDream.App.Rendering;
|
||||||
using AcDream.Core.Plugins;
|
using AcDream.Platform;
|
||||||
using AcDream.Runtime.Platform;
|
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
GraphicalHostPlatformServices graphicalPlatform =
|
GraphicalHostPlatformServices graphicalPlatform =
|
||||||
|
|
@ -32,17 +33,110 @@ Log.Information(
|
||||||
dependency =>
|
dependency =>
|
||||||
$"{dependency.Feature}={dependency.PublishedFileName}")));
|
$"{dependency.Feature}={dependency.PublishedFileName}")));
|
||||||
|
|
||||||
var datDir = args.FirstOrDefault() ?? Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
// Campaign LA slice LA1: --session-config <path> is purely additive — the
|
||||||
if (string.IsNullOrWhiteSpace(datDir))
|
// existing one positional dat-dir argument and every ACDREAM_* env var keep
|
||||||
|
// working exactly as before when the flag is absent. See
|
||||||
|
// docs/plans/2026-08-14-launcher-campaign.md LA1.
|
||||||
|
//
|
||||||
|
// Review fix F5 (LA1 review round): a trailing, valueless --session-config
|
||||||
|
// (the flag typed as the LAST argument, nothing after it) must be a hard
|
||||||
|
// error, never a silent fall-through to the env-var path — a launcher that
|
||||||
|
// mis-composed its argv would otherwise appear to work while quietly
|
||||||
|
// ignoring the session-config contract entirely.
|
||||||
|
string? sessionConfigFlagPath = SessionConfigArgumentParsing.ExtractFlagValue(
|
||||||
|
args, "--session-config", out bool sessionConfigFlagPresent);
|
||||||
|
if (sessionConfigFlagPath is null && sessionConfigFlagPresent)
|
||||||
{
|
{
|
||||||
Log.Error("usage: AcDream.App <dat-directory> (or set ACDREAM_DAT_DIR)");
|
Log.Error(
|
||||||
|
"--session-config requires a value (a path to the session-config document).");
|
||||||
return 2;
|
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
|
// Single read of the startup-time process environment. Every downstream
|
||||||
// consumer (GameWindow + collaborators) reads the typed bundle, not the
|
// consumer (GameWindow + collaborators) reads the typed bundle, not the
|
||||||
// raw env vars. See docs/architecture/code-structure.md §2 Rule 4.
|
// raw env vars. See docs/architecture/code-structure.md §2 Rule 4.
|
||||||
var runtimeOptions = RuntimeOptions.FromEnvironment(datDir);
|
RuntimeOptions runtimeOptions;
|
||||||
|
if (sessionConfigFlagPath is not null)
|
||||||
|
{
|
||||||
|
SessionConfiguration sessionConfig;
|
||||||
|
SessionDescriptor session;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
(sessionConfig, session) = SessionConfigurationLoader.Load(sessionConfigFlagPath);
|
||||||
|
}
|
||||||
|
catch (Exception error)
|
||||||
|
when (error is IOException
|
||||||
|
or UnauthorizedAccessException
|
||||||
|
or ArgumentException
|
||||||
|
or NotSupportedException
|
||||||
|
or System.Text.Json.JsonException
|
||||||
|
or SessionConfigurationException)
|
||||||
|
{
|
||||||
|
Log.Error("--session-config invalid: {Error}", error.Message);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? resolvedDatDir =
|
||||||
|
NullIfEmpty(sessionConfig.Process?.Content?.DatDirectory)
|
||||||
|
?? NullIfEmpty(datDirArg)
|
||||||
|
?? NullIfEmpty(envDatDir);
|
||||||
|
if (resolvedDatDir is null)
|
||||||
|
{
|
||||||
|
Log.Error(
|
||||||
|
"usage: AcDream.App <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;
|
||||||
|
}
|
||||||
|
runtimeOptions = RuntimeOptions.FromEnvironment(datDir);
|
||||||
|
}
|
||||||
|
|
||||||
if (runtimeOptions.DevTools)
|
if (runtimeOptions.DevTools)
|
||||||
{
|
{
|
||||||
|
|
@ -67,76 +161,16 @@ var host = new AppPluginHost(
|
||||||
worldEvents,
|
worldEvents,
|
||||||
window.Selection,
|
window.Selection,
|
||||||
uiRegistry);
|
uiRegistry);
|
||||||
|
GraphicalPluginSession pluginSession = GraphicalPluginSession.Create(
|
||||||
var loaded = new List<LoadedPlugin>();
|
applicationPaths,
|
||||||
var loadedPluginIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
runtimeOptions.Plugins,
|
||||||
StringComparer pathComparer =
|
runtimeOptions.SessionId ?? "app",
|
||||||
graphicalPlatform.OperatingSystem
|
host,
|
||||||
== GraphicalHostOperatingSystem.Windows
|
window.StatusWriter);
|
||||||
? StringComparer.OrdinalIgnoreCase
|
window.StartPluginHosting(pluginSession);
|
||||||
: 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
foreach (var plugin in loaded)
|
|
||||||
{
|
|
||||||
try { plugin.Plugin!.Enable(); }
|
|
||||||
catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); }
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
window.Run();
|
window.Run();
|
||||||
|
|
@ -149,12 +183,14 @@ try
|
||||||
}
|
}
|
||||||
finally
|
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();
|
Log.CloseAndFlush();
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
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;
|
||||||
|
|
|
||||||
157
src/AcDream.App/Rendering/ChargenPreviewAnimator.cs
Normal file
157
src/AcDream.App/Rendering/ChargenPreviewAnimator.cs
Normal 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->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
199
src/AcDream.App/Rendering/ChargenPreviewCamera.cs
Normal file
199
src/AcDream.App/Rendering/ChargenPreviewCamera.cs
Normal 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;
|
||||||
|
}
|
||||||
416
src/AcDream.App/Rendering/ChargenPreviewController.cs
Normal file
416
src/AcDream.App/Rendering/ChargenPreviewController.cs
Normal 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).
|
||||||
|
}
|
||||||
|
}
|
||||||
572
src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs
Normal file
572
src/AcDream.App/Rendering/ChargenPreviewEntityBuilder.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
136
src/AcDream.App/Rendering/ChargenPreviewRenderer.cs
Normal file
136
src/AcDream.App/Rendering/ChargenPreviewRenderer.cs
Normal 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();
|
||||||
|
}
|
||||||
176
src/AcDream.App/Rendering/ChargenPreviewRotationController.cs
Normal file
176
src/AcDream.App/Rendering/ChargenPreviewRotationController.cs
Normal 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);
|
||||||
|
}
|
||||||
162
src/AcDream.App/Rendering/ChargenPreviewZoomController.cs
Normal file
162
src/AcDream.App/Rendering/ChargenPreviewZoomController.cs
Normal 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->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using AcDream.UI.Abstractions.Panels.Settings;
|
||||||
using Silk.NET.Windowing;
|
using Silk.NET.Windowing;
|
||||||
|
|
||||||
namespace AcDream.App.Rendering;
|
namespace AcDream.App.Rendering;
|
||||||
|
|
@ -29,13 +30,28 @@ namespace AcDream.App.Rendering;
|
||||||
internal static class DisplayModeCatalog
|
internal static class DisplayModeCatalog
|
||||||
{
|
{
|
||||||
private static IReadOnlyList<string>? _resolutions;
|
private static IReadOnlyList<string>? _resolutions;
|
||||||
|
private static IReadOnlyList<string>? _windowedResolutions;
|
||||||
private static string? _desktopResolution;
|
private static string? _desktopResolution;
|
||||||
|
|
||||||
/// <summary>The curated list, or null when no catalog was installed
|
/// <summary>The curated HARDWARE mode list, or null when no catalog was
|
||||||
/// (fixture/headless callers — consumers fall back to the static
|
/// installed (fixture/headless callers — consumers fall back to the
|
||||||
/// preset ladder).</summary>
|
/// 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;
|
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
|
/// <summary>The desktop's current mode as a "WxH" string — the Config
|
||||||
/// Resolution row's Defaults value in production (see the class doc for
|
/// Resolution row's Defaults value in production (see the class doc for
|
||||||
/// why this replaces retail's authored 800x600). Null when no catalog
|
/// why this replaces retail's authored 800x600). Null when no catalog
|
||||||
|
|
@ -69,6 +85,7 @@ internal static class DisplayModeCatalog
|
||||||
return;
|
return;
|
||||||
|
|
||||||
_resolutions = curated;
|
_resolutions = curated;
|
||||||
|
_windowedResolutions = BuildWindowedOffering(curated, (desktop.X, desktop.Y));
|
||||||
_desktopResolution = $"{desktop.X}x{desktop.Y}";
|
_desktopResolution = $"{desktop.X}x{desktop.Y}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,9 +93,59 @@ internal static class DisplayModeCatalog
|
||||||
internal static void ResetForTests()
|
internal static void ResetForTests()
|
||||||
{
|
{
|
||||||
_resolutions = null;
|
_resolutions = null;
|
||||||
|
_windowedResolutions = null;
|
||||||
_desktopResolution = 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>
|
/// <summary>
|
||||||
/// The pure curation rule (#391): keep a mode iff
|
/// The pure curation rule (#391): keep a mode iff
|
||||||
/// - it is a modern widescreen format (16:9, 16:10, or ultrawide 21:9 /
|
/// - it is a modern widescreen format (16:9, 16:10, or ultrawide 21:9 /
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ using AcDream.App.Settings;
|
||||||
using AcDream.App.Platform;
|
using AcDream.App.Platform;
|
||||||
using AcDream.App.World;
|
using AcDream.App.World;
|
||||||
using AcDream.Content;
|
using AcDream.Content;
|
||||||
|
using AcDream.Platform;
|
||||||
using AcDream.Runtime;
|
using AcDream.Runtime;
|
||||||
using AcDream.Runtime.Platform;
|
|
||||||
using AcDream.Runtime.Entities;
|
using AcDream.Runtime.Entities;
|
||||||
using AcDream.Runtime.Gameplay;
|
using AcDream.Runtime.Gameplay;
|
||||||
using AcDream.Runtime.Session;
|
using AcDream.Runtime.Session;
|
||||||
|
|
@ -36,6 +36,9 @@ public sealed class GameWindow :
|
||||||
/ (double)System.Diagnostics.Stopwatch.Frequency;
|
/ (double)System.Diagnostics.Stopwatch.Frequency;
|
||||||
|
|
||||||
private readonly AcDream.App.RuntimeOptions _options;
|
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 AnimationPresentationDiagnostics _animationDiagnostics;
|
||||||
private readonly string _datDir;
|
private readonly string _datDir;
|
||||||
private readonly WorldGameState _worldGameState;
|
private readonly WorldGameState _worldGameState;
|
||||||
|
|
@ -134,6 +137,14 @@ public sealed class GameWindow :
|
||||||
_constructionCleanup = new();
|
_constructionCleanup = new();
|
||||||
private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment;
|
private readonly AcDream.App.World.WorldEnvironmentController _worldEnvironment;
|
||||||
private readonly GameWindowLifetime _lifetime = new();
|
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 DisplayFramePacingController _displayFramePacing;
|
||||||
private readonly RuntimeSettingsController _runtimeSettings;
|
private readonly RuntimeSettingsController _runtimeSettings;
|
||||||
|
|
||||||
|
|
@ -359,6 +370,7 @@ public sealed class GameWindow :
|
||||||
private RuntimeActionState _runtimeActions => _runtime.ActionOwner;
|
private RuntimeActionState _runtimeActions => _runtime.ActionOwner;
|
||||||
public AcDream.Core.Selection.SelectionState Selection =>
|
public AcDream.Core.Selection.SelectionState Selection =>
|
||||||
_runtimeActions.Selection;
|
_runtimeActions.Selection;
|
||||||
|
internal SessionStatusWriter StatusWriter => _statusWriter;
|
||||||
public AcDream.Core.Chat.ChatLog Chat => _runtimeCommunication.Chat;
|
public AcDream.Core.Chat.ChatLog Chat => _runtimeCommunication.Chat;
|
||||||
public AcDream.Core.Chat.TurbineChatState TurbineChat =>
|
public AcDream.Core.Chat.TurbineChatState TurbineChat =>
|
||||||
_runtimeCommunication.TurbineChat;
|
_runtimeCommunication.TurbineChat;
|
||||||
|
|
@ -425,8 +437,18 @@ public sealed class GameWindow :
|
||||||
_creatureAppraisalViewportRenderer;
|
_creatureAppraisalViewportRenderer;
|
||||||
private AcDream.App.Rendering.CreatureAppraisalFramePresenter?
|
private AcDream.App.Rendering.CreatureAppraisalFramePresenter?
|
||||||
_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.
|
// Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad.
|
||||||
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
|
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
|
||||||
|
private AcDream.App.Plugins.GraphicalPluginSession? _pluginSession;
|
||||||
// Campaign V slice V11 deleted the ImGui developer-tools frontend along
|
// Campaign V slice V11 deleted the ImGui developer-tools frontend along
|
||||||
// with the OpenGL backend it required, so no host ever composes a
|
// with the OpenGL backend it required, so no host ever composes a
|
||||||
// developer UI regardless of ACDREAM_DEVTOOLS. The flag still reaches
|
// developer UI regardless of ACDREAM_DEVTOOLS. The flag still reaches
|
||||||
|
|
@ -615,6 +637,7 @@ public sealed class GameWindow :
|
||||||
GraphicalHostPlatformServices platformServices)
|
GraphicalHostPlatformServices platformServices)
|
||||||
{
|
{
|
||||||
_options = options ?? throw new System.ArgumentNullException(nameof(options));
|
_options = options ?? throw new System.ArgumentNullException(nameof(options));
|
||||||
|
_statusWriter = new SessionStatusWriter(options.StatusFilePath);
|
||||||
_platformServices = platformServices
|
_platformServices = platformServices
|
||||||
?? throw new ArgumentNullException(nameof(platformServices));
|
?? throw new ArgumentNullException(nameof(platformServices));
|
||||||
_applicationPaths = _platformServices.Paths;
|
_applicationPaths = _platformServices.Paths;
|
||||||
|
|
@ -718,6 +741,24 @@ public sealed class GameWindow :
|
||||||
_movementTruthDiagnostics);
|
_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()
|
public void Run()
|
||||||
{
|
{
|
||||||
_platformServices.ConfigureWindowBackend();
|
_platformServices.ConfigureWindowBackend();
|
||||||
|
|
@ -787,6 +828,10 @@ public sealed class GameWindow :
|
||||||
catch (Exception failure)
|
catch (Exception failure)
|
||||||
{
|
{
|
||||||
_constructionCleanup.RetainFrom(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;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1053,6 +1098,8 @@ public sealed class GameWindow :
|
||||||
|| _paperdollFramePresenter is not null
|
|| _paperdollFramePresenter is not null
|
||||||
|| _creatureAppraisalViewportRenderer is not null
|
|| _creatureAppraisalViewportRenderer is not null
|
||||||
|| _creatureAppraisalFramePresenter is not null
|
|| _creatureAppraisalFramePresenter is not null
|
||||||
|
|| _chargenPreviewRenderer is not null
|
||||||
|
|| _chargenPreviewController is not null
|
||||||
|| _envCellRenderer is not null
|
|| _envCellRenderer is not null
|
||||||
|| _envCellFrustum is not null
|
|| _envCellFrustum is not null
|
||||||
|| _landblockPresentationPipeline is not null
|
|| _landblockPresentationPipeline is not null
|
||||||
|
|
@ -1090,6 +1137,10 @@ public sealed class GameWindow :
|
||||||
_paperdollFramePresenter = result.PaperdollPresenter;
|
_paperdollFramePresenter = result.PaperdollPresenter;
|
||||||
_creatureAppraisalViewportRenderer = result.CreatureAppraisalRenderer;
|
_creatureAppraisalViewportRenderer = result.CreatureAppraisalRenderer;
|
||||||
_creatureAppraisalFramePresenter = result.CreatureAppraisalPresenter;
|
_creatureAppraisalFramePresenter = result.CreatureAppraisalPresenter;
|
||||||
|
_chargenPreviewRenderer = result.ChargenPreviewRenderer;
|
||||||
|
_chargenPreviewController = result.ChargenPreviewController;
|
||||||
|
_summaryPreviewRenderer = result.SummaryPreviewRenderer;
|
||||||
|
_summaryPreviewController = result.SummaryPreviewController;
|
||||||
_envCellFrustum = result.EnvCellFrustum;
|
_envCellFrustum = result.EnvCellFrustum;
|
||||||
_envCellRenderer = result.EnvCellRenderer;
|
_envCellRenderer = result.EnvCellRenderer;
|
||||||
_landblockPresentationPipeline = result.LandblockPipeline;
|
_landblockPresentationPipeline = result.LandblockPipeline;
|
||||||
|
|
@ -1489,7 +1540,8 @@ public sealed class GameWindow :
|
||||||
_combatAttackOperations,
|
_combatAttackOperations,
|
||||||
_combatFeedback,
|
_combatFeedback,
|
||||||
_portalTunnelFallback,
|
_portalTunnelFallback,
|
||||||
Console.WriteLine),
|
Console.WriteLine,
|
||||||
|
_statusWriter),
|
||||||
this).Compose(
|
this).Compose(
|
||||||
hostInputCamera,
|
hostInputCamera,
|
||||||
contentEffectsAudio,
|
contentEffectsAudio,
|
||||||
|
|
@ -1548,7 +1600,8 @@ public sealed class GameWindow :
|
||||||
livePresentation,
|
livePresentation,
|
||||||
sessionPlayer),
|
sessionPlayer),
|
||||||
frameRoots => new SessionStartCompositionPhase(
|
frameRoots => new SessionStartCompositionPhase(
|
||||||
new SessionStartDependencies(Console.WriteLine))
|
new SessionStartDependencies(
|
||||||
|
Console.WriteLine))
|
||||||
.Start(frameRoots));
|
.Start(frameRoots));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1636,13 +1689,30 @@ public sealed class GameWindow :
|
||||||
private void CompleteShutdown(bool releaseNativeWindow)
|
private void CompleteShutdown(bool releaseNativeWindow)
|
||||||
{
|
{
|
||||||
if (!_lifetime.HasShutdownRoots)
|
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());
|
_lifetime.PublishShutdownRoots(CaptureShutdownRoots());
|
||||||
|
}
|
||||||
|
|
||||||
GameWindowLifetimeReport report = releaseNativeWindow
|
GameWindowLifetimeReport report = releaseNativeWindow
|
||||||
? _lifetime.CompleteAndReleaseNativeWindow()
|
? _lifetime.CompleteAndReleaseNativeWindow()
|
||||||
: _lifetime.TryComplete();
|
: _lifetime.TryComplete();
|
||||||
if (report.Status == GameWindowLifetimeStatus.Complete)
|
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;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Console.Error.WriteLine(
|
Console.Error.WriteLine(
|
||||||
$"[shutdown] status={report.Status}, blocked={report.BlockedStage ?? "none"}");
|
$"[shutdown] status={report.Status}, blocked={report.BlockedStage ?? "none"}");
|
||||||
|
|
@ -1655,6 +1725,43 @@ public sealed class GameWindow :
|
||||||
|
|
||||||
if (report.Error is not null)
|
if (report.Error is not null)
|
||||||
Console.Error.WriteLine($"[shutdown] {report.Error}");
|
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(
|
private GameWindowShutdownRoots CaptureShutdownRoots() => new(
|
||||||
|
|
@ -1670,6 +1777,7 @@ public sealed class GameWindow :
|
||||||
_kbSource,
|
_kbSource,
|
||||||
_retailUiLease,
|
_retailUiLease,
|
||||||
_uiHost,
|
_uiHost,
|
||||||
|
_pluginSession,
|
||||||
_runtime,
|
_runtime,
|
||||||
_movementInput,
|
_movementInput,
|
||||||
_cameraInput,
|
_cameraInput,
|
||||||
|
|
@ -1707,6 +1815,10 @@ public sealed class GameWindow :
|
||||||
_portalTunnelFallback,
|
_portalTunnelFallback,
|
||||||
_paperdollViewportRenderer,
|
_paperdollViewportRenderer,
|
||||||
_creatureAppraisalViewportRenderer,
|
_creatureAppraisalViewportRenderer,
|
||||||
|
_chargenPreviewRenderer,
|
||||||
|
_chargenPreviewController,
|
||||||
|
_summaryPreviewRenderer,
|
||||||
|
_summaryPreviewController,
|
||||||
_wbDrawDispatcher,
|
_wbDrawDispatcher,
|
||||||
_envCellRenderer,
|
_envCellRenderer,
|
||||||
_portalDepthMask,
|
_portalDepthMask,
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ internal sealed record IngressShutdownRoots(
|
||||||
RetailUiRuntimeLease RetailUi,
|
RetailUiRuntimeLease RetailUi,
|
||||||
// Keeps failed physical UI bindings alive through native-window release.
|
// Keeps failed physical UI bindings alive through native-window release.
|
||||||
UiHost? RetainedUiHost,
|
UiHost? RetainedUiHost,
|
||||||
|
IDisposable? Plugins,
|
||||||
GameRuntime Runtime,
|
GameRuntime Runtime,
|
||||||
DispatcherMovementInputSource MovementInput,
|
DispatcherMovementInputSource MovementInput,
|
||||||
DispatcherCameraInputSource CameraInput,
|
DispatcherCameraInputSource CameraInput,
|
||||||
|
|
@ -111,6 +112,16 @@ internal sealed record RenderShutdownRoots(
|
||||||
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
|
TransferableResourceSlot<PortalTunnelPresentation> PortalTunnelFallback,
|
||||||
PaperdollViewportRenderer? Paperdoll,
|
PaperdollViewportRenderer? Paperdoll,
|
||||||
CreatureAppraisalViewportRenderer? CreatureAppraisal,
|
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,
|
WbDrawDispatcher? DrawDispatcher,
|
||||||
EnvCellRenderer? EnvironmentCells,
|
EnvCellRenderer? EnvironmentCells,
|
||||||
PortalDepthMaskRenderer? PortalDepthMask,
|
PortalDepthMaskRenderer? PortalDepthMask,
|
||||||
|
|
@ -422,6 +433,10 @@ internal static class GameWindowShutdownManifest
|
||||||
Soft("keyboard source", () => DisposeKeyboardSource(ingress.KeyboardSource)),
|
Soft("keyboard source", () => DisposeKeyboardSource(ingress.KeyboardSource)),
|
||||||
Soft("native window callbacks", () => DisposeWindowCallbacks(ingress.WindowCallbacks)),
|
Soft("native window callbacks", () => DisposeWindowCallbacks(ingress.WindowCallbacks)),
|
||||||
]),
|
]),
|
||||||
|
new ResourceShutdownStage("plugin host",
|
||||||
|
[
|
||||||
|
Hard("plugins", () => ingress.Plugins?.Dispose()),
|
||||||
|
]),
|
||||||
new ResourceShutdownStage("frame borrowers",
|
new ResourceShutdownStage("frame borrowers",
|
||||||
[
|
[
|
||||||
Hard("world frame composition", () => frame.FrameGraphPublication?.Dispose()),
|
Hard("world frame composition", () => frame.FrameGraphPublication?.Dispose()),
|
||||||
|
|
@ -484,6 +499,10 @@ internal static class GameWindowShutdownManifest
|
||||||
Hard(
|
Hard(
|
||||||
"creature appraisal viewport",
|
"creature appraisal viewport",
|
||||||
() => render.CreatureAppraisal?.Dispose()),
|
() => 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("mesh draw dispatcher", () => render.DrawDispatcher?.Dispose()),
|
||||||
Hard("environment cells", () => render.EnvironmentCells?.Dispose()),
|
Hard("environment cells", () => render.EnvironmentCells?.Dispose()),
|
||||||
Hard("portal depth mask", () => render.PortalDepthMask?.Dispose()),
|
Hard("portal depth mask", () => render.PortalDepthMask?.Dispose()),
|
||||||
|
|
|
||||||
|
|
@ -335,29 +335,11 @@ internal sealed class RetailPaperdollPoseApplicator : IPaperdollPoseApplicator
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retail <c>gmPaperDollUI</c> resolves its held pose with
|
/// Retail <c>gmPaperDollUI</c> resolves its held pose with
|
||||||
/// <c>DBCache::GetDIDFromEnumStatic(0x10000005, 7)</c>. The master map
|
/// <c>DBCache::GetDIDFromEnumStatic(0x10000005, 7)</c> —
|
||||||
/// therefore resolves key 7 to a sub-map, then key 0x10000005 to the
|
/// <see cref="RetailHeldPose.ResolvePoseDid"/> parameterized by the
|
||||||
/// Animation DID.
|
/// paperdoll's own fixed enum key.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private uint ResolvePoseDid()
|
private uint ResolvePoseDid() => RetailHeldPose.ResolvePoseDid(_dats, 0x10000005u);
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Apply(WorldEntity doll, uint setupId)
|
public void Apply(WorldEntity doll, uint setupId)
|
||||||
{
|
{
|
||||||
|
|
@ -392,9 +374,7 @@ internal sealed class RetailPaperdollPoseApplicator : IPaperdollPoseApplicator
|
||||||
orientation = frame.Frames[index].Orientation;
|
orientation = frame.Frames[index].Orientation;
|
||||||
}
|
}
|
||||||
|
|
||||||
Matrix4x4 transform = Matrix4x4.CreateScale(scale)
|
Matrix4x4 transform = RetailHeldPose.ComposePartTransform(scale, origin, orientation);
|
||||||
* Matrix4x4.CreateFromQuaternion(orientation)
|
|
||||||
* Matrix4x4.CreateTranslation(origin);
|
|
||||||
MeshRef source = doll.MeshRefs[index];
|
MeshRef source = doll.MeshRefs[index];
|
||||||
reposed.Add(new MeshRef(source.GfxObjId, transform)
|
reposed.Add(new MeshRef(source.GfxObjId, transform)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,20 @@ internal interface IPrivateEntityViewportCamera : ICamera
|
||||||
/// raw-GL <c>WbDrawDispatcher</c> into (through V10, §5.5.6) was deleted at
|
/// 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
|
/// Campaign V slice V11: <c>WbDrawDispatcher</c> now records into the pass
|
||||||
/// this renderer publishes on both call sites the same way.</para>
|
/// 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>
|
/// </summary>
|
||||||
internal sealed class PrivateEntityViewportRenderer :
|
internal sealed class PrivateEntityViewportRenderer :
|
||||||
IUiViewportRenderer,
|
IUiViewportRenderer,
|
||||||
|
|
@ -68,21 +82,23 @@ internal sealed class PrivateEntityViewportRenderer :
|
||||||
|
|
||||||
private readonly WbDrawDispatcher _dispatcher;
|
private readonly WbDrawDispatcher _dispatcher;
|
||||||
private readonly SceneLightingUboBinding _lightUbo;
|
private readonly SceneLightingUboBinding _lightUbo;
|
||||||
private readonly FixedEntityTextureOwnerLease _textureOwnerLease;
|
|
||||||
private readonly IWbMeshAdapter _meshAdapter;
|
private readonly IWbMeshAdapter _meshAdapter;
|
||||||
private readonly IPrivateEntityViewportCamera _camera;
|
private readonly IPrivateEntityViewportCamera _camera;
|
||||||
private readonly HashSet<uint> _animatedIds;
|
private readonly HashSet<uint> _animatedIds;
|
||||||
private readonly string _diagnosticName;
|
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 IGpuRenderTarget? _target;
|
||||||
private IGpuSampler? _sampler;
|
private IGpuSampler? _sampler;
|
||||||
private GpuTextureSlot _slot = GpuTextureSlot.Unassigned;
|
private GpuTextureSlot _slot = GpuTextureSlot.Unassigned;
|
||||||
private int _fbW;
|
private int _fbW;
|
||||||
private int _fbH;
|
private int _fbH;
|
||||||
private WorldEntity? _entity;
|
|
||||||
private SyntheticEntityMeshReferenceOwner? _meshReferences;
|
|
||||||
|
|
||||||
public PrivateEntityViewportRenderer(
|
public PrivateEntityViewportRenderer(
|
||||||
IWorldPassScope scope,
|
IWorldPassScope scope,
|
||||||
|
|
@ -94,10 +110,13 @@ internal sealed class PrivateEntityViewportRenderer :
|
||||||
IWbMeshAdapter meshAdapter,
|
IWbMeshAdapter meshAdapter,
|
||||||
uint renderId,
|
uint renderId,
|
||||||
IPrivateEntityViewportCamera camera,
|
IPrivateEntityViewportCamera camera,
|
||||||
string diagnosticName)
|
string diagnosticName,
|
||||||
|
uint? backdropRenderId = null)
|
||||||
{
|
{
|
||||||
if (renderId == 0u)
|
if (renderId == 0u)
|
||||||
throw new ArgumentOutOfRangeException(nameof(renderId));
|
throw new ArgumentOutOfRangeException(nameof(renderId));
|
||||||
|
if (backdropRenderId == 0u)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(backdropRenderId));
|
||||||
|
|
||||||
_scope = scope ?? throw new ArgumentNullException(
|
_scope = scope ?? throw new ArgumentNullException(
|
||||||
nameof(scope),
|
nameof(scope),
|
||||||
|
|
@ -112,10 +131,33 @@ internal sealed class PrivateEntityViewportRenderer :
|
||||||
_diagnosticName = string.IsNullOrWhiteSpace(diagnosticName)
|
_diagnosticName = string.IsNullOrWhiteSpace(diagnosticName)
|
||||||
? "creature viewport"
|
? "creature viewport"
|
||||||
: diagnosticName;
|
: diagnosticName;
|
||||||
_animatedIds = [renderId];
|
|
||||||
_textureOwnerLease = new FixedEntityTextureOwnerLease(
|
IEntityTextureLifetime textureLifetimeChecked = textureLifetime
|
||||||
textureLifetime ?? throw new ArgumentNullException(nameof(textureLifetime)),
|
?? throw new ArgumentNullException(nameof(textureLifetime));
|
||||||
renderId);
|
|
||||||
|
_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>
|
/// <summary>
|
||||||
|
|
@ -125,65 +167,27 @@ internal sealed class PrivateEntityViewportRenderer :
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool TextureIsBottomUp => false;
|
public bool TextureIsBottomUp => false;
|
||||||
|
|
||||||
public void SetEntity(WorldEntity? entity)
|
public void SetEntity(WorldEntity? entity) => _mainSlot.Set(entity);
|
||||||
{
|
|
||||||
ReleaseRetiringMeshReferences();
|
|
||||||
|
|
||||||
if (ReferenceEquals(_entity, entity))
|
/// <summary>
|
||||||
return;
|
/// 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
|
||||||
SyntheticEntityMeshReferenceOwner? replacement = null;
|
/// valid on a renderer constructed with a <c>backdropRenderId</c>
|
||||||
if (entity is not null)
|
/// (<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(
|
if (_backdropSlot is null)
|
||||||
_meshAdapter,
|
{
|
||||||
CollectMeshIds(entity));
|
throw new InvalidOperationException(
|
||||||
replacement.Acquire();
|
$"The {_diagnosticName} was not constructed with a "
|
||||||
|
+ "backdropRenderId and cannot render a second (backdrop) entity.");
|
||||||
}
|
}
|
||||||
|
|
||||||
SyntheticEntityMeshReferenceOwner? previous = _meshReferences;
|
_backdropSlot.Set(entity);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -193,7 +197,7 @@ internal sealed class PrivateEntityViewportRenderer :
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public uint Render(int width, int height)
|
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)
|
if (entity is null || entity.MeshRefs.Count == 0 || width <= 0 || height <= 0)
|
||||||
return 0u;
|
return 0u;
|
||||||
|
|
||||||
|
|
@ -232,7 +236,7 @@ internal sealed class PrivateEntityViewportRenderer :
|
||||||
|
|
||||||
UploadCreatureLight();
|
UploadCreatureLight();
|
||||||
|
|
||||||
WorldEntity[] entities = [entity];
|
IReadOnlyList<WorldEntity> drawEntities = BuildDrawEntities(_backdropSlot?.Entity, entity);
|
||||||
var entries =
|
var entries =
|
||||||
new (uint, Vector3, Vector3, IReadOnlyList<WorldEntity>,
|
new (uint, Vector3, Vector3, IReadOnlyList<WorldEntity>,
|
||||||
IReadOnlyDictionary<uint, WorldEntity>?)[]
|
IReadOnlyDictionary<uint, WorldEntity>?)[]
|
||||||
|
|
@ -241,7 +245,7 @@ internal sealed class PrivateEntityViewportRenderer :
|
||||||
PrivateLandblockId,
|
PrivateLandblockId,
|
||||||
new Vector3(-1024f),
|
new Vector3(-1024f),
|
||||||
new Vector3(1024f),
|
new Vector3(1024f),
|
||||||
entities,
|
drawEntities,
|
||||||
null),
|
null),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -255,9 +259,34 @@ internal sealed class PrivateEntityViewportRenderer :
|
||||||
return UiTextureTableHandle.FromSlot(_slot);
|
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>
|
/// <summary>
|
||||||
/// Both retail paperdoll and creature examination call
|
/// Both retail paperdoll and creature examination call
|
||||||
/// <c>UIElement_Viewport::SetLight(DISTANT_LIGHT, 2, (0.3,1.9,0.65))</c>.
|
/// <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>
|
/// </summary>
|
||||||
private void UploadCreatureLight()
|
private void UploadCreatureLight()
|
||||||
{
|
{
|
||||||
|
|
@ -342,17 +371,10 @@ internal sealed class PrivateEntityViewportRenderer :
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_entity = null;
|
|
||||||
if (_meshReferences is { } current)
|
|
||||||
{
|
|
||||||
_meshReferences = null;
|
|
||||||
_retiringMeshReferences.Add(current);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Exception>? failures = null;
|
List<Exception>? failures = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_textureOwnerLease.Dispose();
|
_mainSlot.Dispose();
|
||||||
}
|
}
|
||||||
catch (Exception error)
|
catch (Exception error)
|
||||||
{
|
{
|
||||||
|
|
@ -360,7 +382,7 @@ internal sealed class PrivateEntityViewportRenderer :
|
||||||
}
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
ReleaseRetiringMeshReferences();
|
_backdropSlot?.Dispose();
|
||||||
}
|
}
|
||||||
catch (Exception error)
|
catch (Exception error)
|
||||||
{
|
{
|
||||||
|
|
@ -391,6 +413,133 @@ internal sealed class PrivateEntityViewportRenderer :
|
||||||
yield return entity.PartOverrides[i].GfxObjId;
|
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()
|
private void ReleaseRetiringMeshReferences()
|
||||||
{
|
{
|
||||||
List<Exception>? failures = null;
|
List<Exception>? failures = null;
|
||||||
|
|
@ -417,4 +566,5 @@ internal sealed class PrivateEntityViewportRenderer :
|
||||||
failures);
|
failures);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
61
src/AcDream.App/Rendering/RetailHeldPose.cs
Normal file
61
src/AcDream.App/Rendering/RetailHeldPose.cs
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
/// <summary>Begin a HUD pass. Call once per frame before any Draw* calls.</summary>
|
||||||
public void Begin(Vector2 screenSize)
|
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,
|
public void DrawSprite(uint texture, float x, float y, float w, float h,
|
||||||
float u0, float v0, float u1, float v1, Vector4 tint)
|
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
|
SpriteSeg seg = OverlayMode
|
||||||
? NextSpriteSeg(_overlaySpriteSegs, ref _overlaySegUsed, texture)
|
? NextSpriteSeg(_overlaySpriteSegs, ref _overlaySegUsed, texture)
|
||||||
: NextSpriteSeg(_spriteSegs, ref _segUsed, texture);
|
: NextSpriteSeg(_spriteSegs, ref _segUsed, texture);
|
||||||
|
|
@ -388,10 +429,19 @@ public sealed class TextRenderer : IDisposable
|
||||||
return ns;
|
return ns;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AppendQuad(List<float> buf,
|
private void AppendQuad(List<float> buf,
|
||||||
float x, float y, float w, float h,
|
float x, float y, float w, float h,
|
||||||
float u0, float v0, float u1, float v1, Vector4 color)
|
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
|
// Two triangles (6 verts). CCW in pixel space is clockwise in NDC
|
||||||
// because the vertex shader flips Y, so OpenGL's default front-face
|
// 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.
|
// is GL_CCW — we rely on cull-face being disabled during HUD pass.
|
||||||
|
|
|
||||||
|
|
@ -40,12 +40,33 @@ public sealed class TextureCache
|
||||||
// Surface→SurfaceTexture chain that GetOrUpload uses for world materials.
|
// Surface→SurfaceTexture chain that GetOrUpload uses for world materials.
|
||||||
private readonly Dictionary<uint, GpuUiTextureEntry> _renderSurfaceGpuTextures = new();
|
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
|
// 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
|
// (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
|
// of the keyed caches above, so Dispose must sweep this list to avoid leaking
|
||||||
// GPU texture objects/slots until process exit.
|
// GPU texture objects/slots until process exit.
|
||||||
private readonly List<GpuUiTextureEntry> _adhocGpuTextures = new();
|
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 readonly CompositeTextureArrayCache? _compositeTextures;
|
||||||
private bool _destinationRevealUploadPriority;
|
private bool _destinationRevealUploadPriority;
|
||||||
|
|
||||||
|
|
@ -231,6 +252,12 @@ public sealed class TextureCache
|
||||||
}
|
}
|
||||||
else
|
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;
|
decoded = DecodedTexture.Magenta;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -346,6 +373,14 @@ public sealed class TextureCache
|
||||||
|
|
||||||
IGpuSampler sampler = _device.CreateSampler(nearest ? UiNearestRepeat : GpuSamplerDescription.WorldRepeat);
|
IGpuSampler sampler = _device.CreateSampler(nearest ? UiNearestRepeat : GpuSamplerDescription.WorldRepeat);
|
||||||
GpuTextureSlot slot = _device.RegisterTexture(texture, sampler);
|
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);
|
return new GpuUiTextureEntry(texture, slot, glName, decoded.Width, decoded.Height);
|
||||||
}
|
}
|
||||||
catch
|
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>
|
/// <summary>
|
||||||
/// The identity a UI upload is accounted under. There is no GL name on the
|
/// 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
|
/// Vulkan-only backend, so a descending synthetic counter supplies one; the
|
||||||
|
|
@ -981,6 +1070,15 @@ public sealed class TextureCache
|
||||||
|
|
||||||
_paletteIndexedByTexture.Clear();
|
_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
|
// RenderSurface (UI sprite) textures — Campaign V slice V4a: each
|
||||||
// entry's IGpuTexture.Dispose() releases the underlying GL name
|
// entry's IGpuTexture.Dispose() releases the underlying GL name
|
||||||
// through the device's own retirement queue, so only the memory-
|
// through the device's own retirement queue, so only the memory-
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using AcDream.App.Interaction;
|
using AcDream.App.Interaction;
|
||||||
using AcDream.App.Net;
|
using AcDream.App.Net;
|
||||||
|
using AcDream.Core.CharGen;
|
||||||
using AcDream.Runtime;
|
using AcDream.Runtime;
|
||||||
using AcDream.Runtime.Session;
|
using AcDream.Runtime.Session;
|
||||||
using AcDream.Runtime.World;
|
using AcDream.Runtime.World;
|
||||||
|
|
@ -20,6 +21,8 @@ internal sealed class CurrentGameRuntimeAdapter
|
||||||
{
|
{
|
||||||
private readonly GameRuntime _runtime;
|
private readonly GameRuntime _runtime;
|
||||||
private readonly CurrentGameRuntimeCommandAdapter _commands;
|
private readonly CurrentGameRuntimeCommandAdapter _commands;
|
||||||
|
private readonly CharacterSelectionProjection _characterSelection;
|
||||||
|
private readonly CharacterCreationProjection _characterCreation;
|
||||||
private readonly IDisposable _hostLease;
|
private readonly IDisposable _hostLease;
|
||||||
private readonly object _subscriptionGate = new();
|
private readonly object _subscriptionGate = new();
|
||||||
private readonly HashSet<AdapterSubscription> _subscriptions = [];
|
private readonly HashSet<AdapterSubscription> _subscriptions = [];
|
||||||
|
|
@ -40,6 +43,8 @@ internal sealed class CurrentGameRuntimeAdapter
|
||||||
"graphical game-runtime command adapter");
|
"graphical game-runtime command adapter");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
_characterSelection = new CharacterSelectionProjection(this);
|
||||||
|
_characterCreation = new CharacterCreationProjection(this);
|
||||||
_commands = new CurrentGameRuntimeCommandAdapter(
|
_commands = new CurrentGameRuntimeCommandAdapter(
|
||||||
runtime.Session,
|
runtime.Session,
|
||||||
sessionHost,
|
sessionHost,
|
||||||
|
|
@ -61,7 +66,7 @@ internal sealed class CurrentGameRuntimeAdapter
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool IsActive =>
|
private bool IsActive =>
|
||||||
!_disposed
|
!Volatile.Read(ref _disposed)
|
||||||
&& !_runtime.Session.IsDisposalComplete;
|
&& !_runtime.Session.IsDisposalComplete;
|
||||||
|
|
||||||
public RuntimeGenerationToken Generation => _runtime.Generation;
|
public RuntimeGenerationToken Generation => _runtime.Generation;
|
||||||
|
|
@ -89,6 +94,10 @@ internal sealed class CurrentGameRuntimeAdapter
|
||||||
public IRuntimeCharacterView Character => _runtime.Character;
|
public IRuntimeCharacterView Character => _runtime.Character;
|
||||||
public IRuntimeSocialView Social => _runtime.Social;
|
public IRuntimeSocialView Social => _runtime.Social;
|
||||||
public IRuntimeChatView Chat => _runtime.Chat;
|
public IRuntimeChatView Chat => _runtime.Chat;
|
||||||
|
public IRuntimeCharacterSelectionView CharacterSelection =>
|
||||||
|
_characterSelection;
|
||||||
|
public IRuntimeCharacterCreationView CharacterCreation =>
|
||||||
|
_characterCreation;
|
||||||
public IRuntimeFellowshipView Fellowship => _runtime.Fellowship;
|
public IRuntimeFellowshipView Fellowship => _runtime.Fellowship;
|
||||||
public IRuntimeAllegianceView Allegiance => _runtime.Allegiance;
|
public IRuntimeAllegianceView Allegiance => _runtime.Allegiance;
|
||||||
public IRuntimeActionView Actions => _runtime.Actions;
|
public IRuntimeActionView Actions => _runtime.Actions;
|
||||||
|
|
@ -97,6 +106,14 @@ internal sealed class CurrentGameRuntimeAdapter
|
||||||
public IRuntimePortalView Portal => _runtime.Portal;
|
public IRuntimePortalView Portal => _runtime.Portal;
|
||||||
|
|
||||||
public IRuntimeSessionCommands Session => _commands;
|
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 IRuntimeSelectionCommands Selection => _commands;
|
||||||
public IRuntimeCombatCommands Combat => _commands;
|
public IRuntimeCombatCommands Combat => _commands;
|
||||||
public IRuntimeMagicCommands Magic => _commands;
|
public IRuntimeMagicCommands Magic => _commands;
|
||||||
|
|
@ -160,6 +177,400 @@ internal sealed class CurrentGameRuntimeAdapter
|
||||||
_subscriptions.Remove(subscription);
|
_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(
|
private sealed class AdapterSubscription(
|
||||||
CurrentGameRuntimeAdapter owner,
|
CurrentGameRuntimeAdapter owner,
|
||||||
IDisposable runtimeSubscription) : IDisposable
|
IDisposable runtimeSubscription) : IDisposable
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text;
|
||||||
|
using AcDream.App.Configuration;
|
||||||
using AcDream.App.Rendering.Residency;
|
using AcDream.App.Rendering.Residency;
|
||||||
using AcDream.App.Streaming;
|
using AcDream.App.Streaming;
|
||||||
|
using AcDream.Runtime.Session;
|
||||||
|
|
||||||
namespace AcDream.App;
|
namespace AcDream.App;
|
||||||
|
|
||||||
|
|
@ -48,6 +53,12 @@ public sealed record RuntimeOptions(
|
||||||
bool DumpClothing,
|
bool DumpClothing,
|
||||||
int? LegacyStreamRadius,
|
int? LegacyStreamRadius,
|
||||||
bool RetailUi,
|
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,
|
string? AcDir,
|
||||||
bool UiProbeDump,
|
bool UiProbeDump,
|
||||||
string? UiProbeScript,
|
string? UiProbeScript,
|
||||||
|
|
@ -62,7 +73,34 @@ public sealed record RuntimeOptions(
|
||||||
string? VulkanDeviceOverride,
|
string? VulkanDeviceOverride,
|
||||||
string? VulkanForcedUnsupportedFeature,
|
string? VulkanForcedUnsupportedFeature,
|
||||||
bool VulkanCapabilityProbe,
|
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>
|
/// <summary>
|
||||||
/// Build options from the process environment. Used by
|
/// 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.
|
// top of the quality preset's radii. Null when unset or invalid.
|
||||||
LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")),
|
LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")),
|
||||||
RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")),
|
RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")),
|
||||||
|
OpenCharacterCreationOnStart:
|
||||||
|
IsExactlyOne(env("ACDREAM_OPEN_CHARGEN")),
|
||||||
AcDir: NullIfEmpty(env("ACDREAM_AC_DIR")),
|
AcDir: NullIfEmpty(env("ACDREAM_AC_DIR")),
|
||||||
UiProbeDump: IsExactlyOne(env("ACDREAM_UI_PROBE_DUMP")),
|
UiProbeDump: IsExactlyOne(env("ACDREAM_UI_PROBE_DUMP")),
|
||||||
UiProbeScript: NullIfEmpty(env("ACDREAM_UI_PROBE_SCRIPT")),
|
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 --
|
// closes the window. Zero -- unset, unparseable, or an explicit 0 --
|
||||||
// keeps the interactive behaviour, so no existing invocation changes.
|
// keeps the interactive behaviour, so no existing invocation changes.
|
||||||
VulkanCapabilityProbeFrames:
|
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>
|
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary>
|
||||||
|
|
|
||||||
|
|
@ -87,12 +87,23 @@ internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarg
|
||||||
: this(
|
: this(
|
||||||
new SilkWindowSizeSurface(window),
|
new SilkWindowSizeSurface(window),
|
||||||
new GlfwDisplayModeSwitcher(window),
|
new GlfwDisplayModeSwitcher(window),
|
||||||
// #391's catalog is the validation source. With no catalog
|
// F9 correction (Campaign CC gate round 1 closeout, 2026-08-16):
|
||||||
// installed, the dropdown falls back to the static preset
|
// this used to claim the validator "must fall back to the SAME
|
||||||
// ladder — the validator must fall back to the SAME list
|
// list" the Config dropdown offers — true when this comment was
|
||||||
// (blast M2: an asymmetric fallback made Full Screen a permanent
|
// written (#391, one catalog for both), but #407 split the
|
||||||
// silent no-op on catalog-less hosts). The switcher's own
|
// catalog in two: WindowedResolutions (the dropdown's fuller
|
||||||
// monitor-mode-list check remains the hard guard either way.
|
// 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
|
spec => (Rendering.DisplayModeCatalog.Resolutions
|
||||||
?? DisplaySettings.AvailableResolutions).Contains(spec))
|
?? DisplaySettings.AvailableResolutions).Contains(spec))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,9 @@ public sealed class GameplayConfirmationController : IDisposable
|
||||||
? request.Message + " Continue?"
|
? request.Message + " Continue?"
|
||||||
: _composeMessage?.Invoke(request.Type, request.Message)
|
: _composeMessage?.Invoke(request.Type, request.Message)
|
||||||
?? request.Message;
|
?? request.Message;
|
||||||
var data = RetailDialogData.Confirmation(message)
|
// ElementAttribute40 now comes from RetailDialogData.Confirmation
|
||||||
.Set(RetailDialogProperty.ElementAttribute40, true);
|
// itself (batch review F5 — retail's confirmation builders all set it).
|
||||||
|
var data = RetailDialogData.Confirmation(message);
|
||||||
_dialogContext = _dialogs.MakeDialog(data);
|
_dialogContext = _dialogs.MakeDialog(data);
|
||||||
return _dialogContext != 0u;
|
return _dialogContext != 0u;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,22 @@ public static class RetailUiStateIds
|
||||||
public const uint LockedUi = 0x10000063u;
|
public const uint LockedUi = 0x10000063u;
|
||||||
public const uint UnlockedUi = 0x10000064u;
|
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)
|
public static string StateName(uint stateId)
|
||||||
=> stateId switch
|
=> stateId switch
|
||||||
{
|
{
|
||||||
|
|
@ -38,6 +54,8 @@ public static class RetailUiStateIds
|
||||||
Minimized => "Minimized",
|
Minimized => "Minimized",
|
||||||
LockedUi => "LockedUI",
|
LockedUi => "LockedUI",
|
||||||
UnlockedUi => "UnlockedUI",
|
UnlockedUi => "UnlockedUI",
|
||||||
|
Unselected => "Unselected",
|
||||||
|
Selected => "Selected",
|
||||||
_ => "",
|
_ => "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -56,6 +74,8 @@ public static class RetailUiStateIds
|
||||||
"Minimized" => Minimized,
|
"Minimized" => Minimized,
|
||||||
"LockedUI" => LockedUi,
|
"LockedUI" => LockedUi,
|
||||||
"UnlockedUI" => UnlockedUi,
|
"UnlockedUI" => UnlockedUi,
|
||||||
|
"Unselected" => Unselected,
|
||||||
|
"Selected" => Selected,
|
||||||
_ => 0u,
|
_ => 0u,
|
||||||
};
|
};
|
||||||
return stateId != 0;
|
return stateId != 0;
|
||||||
|
|
|
||||||
1335
src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs
Normal file
1335
src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs
Normal file
File diff suppressed because it is too large
Load diff
282
src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs
Normal file
282
src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs
Normal 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 -> heritage-id map), <c>gmCGHeritagePage::Update @
|
||||||
|
/// 0x00483210</c> (description text composition).
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class CharacterCreationHeritagePage : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Button element id -> <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_<Abbrev>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->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
383
src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs
Normal file
383
src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs
Normal 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 -> 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 -> 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 -> 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->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 < 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
941
src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs
Normal file
941
src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs
Normal 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><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 <= 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 <= 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, &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> -> 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 -> 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>< 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 -> Trained,
|
||||||
|
/// Trained -> 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 -> Trained,
|
||||||
|
/// Trained -> 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>> 1</c> gets the
|
||||||
|
/// parenthesized multiply form, else just the attribute's name — the
|
||||||
|
/// exact <c>eax_6 <= 1</c>/<c>ebx_3 <= 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
550
src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs
Normal file
550
src/AcDream.App/UI/Layout/CharacterCreationSummaryPage.cs
Normal 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
|
||||||
|
/// >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 > 33</c> is EXACTLY <c>visibleChars > 32</c>: a
|
||||||
|
/// 32-character name has length 33 (not <c>> 33</c>, accepted), a
|
||||||
|
/// 33-character name has length 34 (<c>> 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 -> (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_<Abbrev>{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;
|
||||||
|
}
|
||||||
|
}
|
||||||
171
src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs
Normal file
171
src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs
Normal 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 ->
|
||||||
|
/// <c>CharGenState::SetStartArea(arg2 - 1)</c> literal index map: Holtburg
|
||||||
|
/// -> 0, Shoushi -> 1, Yaraq -> 2, Sanamar -> 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 -> 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 -> 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->vtable->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->0x10000034,
|
||||||
|
/// Shoushi->0x10000037, Yaraq->0x10000036, Sanamar->0x10000035.
|
||||||
|
/// Re-asserted directly (inlined, bypassing SetTown) at the Sanamar
|
||||||
|
/// click site @0x0047c518. Review fix round F4 (2026-08-15): only the
|
||||||
|
/// master page's state cycling was ported — this page's own state was
|
||||||
|
/// missed entirely.
|
||||||
|
/// </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();
|
||||||
|
}
|
||||||
|
}
|
||||||
1134
src/AcDream.App/UI/Layout/CharacterCreationUiController.cs
Normal file
1134
src/AcDream.App/UI/Layout/CharacterCreationUiController.cs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
828
src/AcDream.App/UI/Layout/CharacterManagementUiController.cs
Normal file
828
src/AcDream.App/UI/Layout/CharacterManagementUiController.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
206
src/AcDream.App/UI/Layout/ChargenColorSpotComposer.cs
Normal file
206
src/AcDream.App/UI/Layout/ChargenColorSpotComposer.cs
Normal 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 >= count</c> branch) — shown UNTINTED for a swatch beyond the
|
||||||
|
/// current part's real color count (retail's own
|
||||||
|
/// <c>pColor->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -207,7 +207,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
||||||
/// <param name="layout">Widget tree from <see cref="LayoutImporter.Build"/>.</param>
|
/// <param name="layout">Widget tree from <see cref="LayoutImporter.Build"/>.</param>
|
||||||
/// <param name="vm">Chat view-model (transcript data + command routing).</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.
|
/// <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
|
/// 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>
|
/// (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
|
/// <param name="windowFilters">Runtime's canonical per-window filter/open state
|
||||||
|
|
|
||||||
106
src/AcDream.App/UI/Layout/DatRichText.cs
Normal file
106
src/AcDream.App/UI/Layout/DatRichText.cs
Normal 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> -> <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;
|
||||||
|
}
|
||||||
|
|
@ -122,6 +122,8 @@ public static class DatWidgetFactory
|
||||||
11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137)
|
11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137)
|
||||||
12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text
|
12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text
|
||||||
0x13 => new UiDialogRoot(), // ConfirmationDialog
|
0x13 => new UiDialogRoot(), // ConfirmationDialog
|
||||||
|
0x15 => new UiDialogRoot(), // ConfirmationTextInputDialog
|
||||||
|
0x17 => new UiDialogRoot(), // MessageDialog
|
||||||
0x19 => new UiDialogRoot(), // WaitDialog (catalog root 0x31 — OP8 #396)
|
0x19 => new UiDialogRoot(), // WaitDialog (catalog root 0x31 — OP8 #396)
|
||||||
0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots
|
0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots
|
||||||
0x10000035u => BuildCheckbox(
|
0x10000035u => BuildCheckbox(
|
||||||
|
|
@ -313,6 +315,31 @@ public static class DatWidgetFactory
|
||||||
if (slices.Length > 0) bar.ThumbTopSprite = DefaultImage(slices[0]);
|
if (slices.Length > 0) bar.ThumbTopSprite = DefaultImage(slices[0]);
|
||||||
if (slices.Length > 1) bar.ThumbSprite = DefaultImage(slices[1]);
|
if (slices.Length > 1) bar.ThumbSprite = DefaultImage(slices[1]);
|
||||||
if (slices.Length > 2) bar.ThumbBotSprite = 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;
|
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
|
// ElementInfo.Outline's own default, so this is a no-op for the ~99% of text
|
||||||
// elements that don't author it.
|
// elements that don't author it.
|
||||||
Outline = info.Outline,
|
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);
|
t.ConfigureDatState(info);
|
||||||
|
|
||||||
|
|
@ -779,7 +814,12 @@ public static class DatWidgetFactory
|
||||||
cachedWidth = t.Width;
|
cachedWidth = t.Width;
|
||||||
cachedFont = t.DatFont;
|
cachedFont = t.DatFont;
|
||||||
cachedColor = t.DefaultColor;
|
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
|
Func<string, float> measure = t.DatFont is { } font
|
||||||
? font.MeasureWidth
|
? font.MeasureWidth
|
||||||
: static value => value.Length * 8f;
|
: static value => value.Length * 8f;
|
||||||
|
|
@ -808,7 +848,8 @@ public static class DatWidgetFactory
|
||||||
|| !state.Properties.Values.TryGetValue(0x17u, out var stateCaption)
|
|| !state.Properties.Values.TryGetValue(0x17u, out var stateCaption)
|
||||||
|| stateCaption.Kind != UiPropertyKind.StringInfo)
|
|| stateCaption.Kind != UiPropertyKind.StringInfo)
|
||||||
continue;
|
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;
|
(stateStrings ??= new Dictionary<uint, string>())[stateId] = text;
|
||||||
}
|
}
|
||||||
if (stateStrings is not null)
|
if (stateStrings is not null)
|
||||||
|
|
@ -876,18 +917,185 @@ public static class DatWidgetFactory
|
||||||
button.FaceTop = face.Y;
|
button.FaceTop = face.Y;
|
||||||
button.FaceWidth = face.Width;
|
button.FaceWidth = face.Width;
|
||||||
button.FaceHeight = face.Height;
|
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;
|
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;
|
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;
|
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>
|
/// <summary>
|
||||||
/// Retail UIOption_Checkbox is a UIElement_Button whose visible face is its
|
/// Retail UIOption_Checkbox is a UIElement_Button whose visible face is its
|
||||||
/// authored indicator child. Its label lives on the option object rather than
|
/// 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)
|
|| !info.TryGetEffectiveProperty(0x17u, out var property)
|
||||||
|| property.Kind != UiPropertyKind.StringInfo)
|
|| property.Kind != UiPropertyKind.StringInfo)
|
||||||
return null;
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using AcDream.App.UI;
|
using AcDream.App.UI;
|
||||||
|
|
||||||
|
|
@ -225,6 +226,46 @@ public sealed class ElementInfo
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public uint ScrollbarElementId;
|
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->vtable->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>
|
/// <summary>
|
||||||
/// Resolves a property for a state using retail's DirectState-as-base rule. A
|
/// 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.
|
/// named state's key overrides DirectState by presence, including false/zero.
|
||||||
|
|
@ -401,6 +442,15 @@ public static class ElementReader
|
||||||
Outline = derived.Outline || base_.Outline,
|
Outline = derived.Outline || base_.Outline,
|
||||||
// OutlineColor: same "non-null derived wins" rule as FontColor.
|
// OutlineColor: same "non-null derived wins" rule as FontColor.
|
||||||
OutlineColor = derived.OutlineColor ?? base_.OutlineColor,
|
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: derived wins if set; otherwise inherit the base's default.
|
||||||
DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName,
|
DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName,
|
||||||
// This helper merges one element snapshot only. LayoutImporter separately
|
// 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
|
// Tab table (0x2E): array of StructBaseProperty (MasterPropertyId 0x2F) — the
|
||||||
// Type-8 tab control's authored {button element, page element, isDefault} rows
|
// 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
|
// (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).
|
// (DataId), UnsignedValue 100683031/100683033 == 0x06004D17/0x06004D19).
|
||||||
info.LedCheckedSprite = ReadReferencedElementId(info, 0x10000082u);
|
info.LedCheckedSprite = ReadReferencedElementId(info, 0x10000082u);
|
||||||
info.LedUncheckedSprite = ReadReferencedElementId(info, 0x10000083u);
|
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)
|
private static List<UiTabTableEntry> ReadTabTable(ElementInfo info)
|
||||||
|
|
@ -644,4 +718,65 @@ public static class ElementReader
|
||||||
})
|
})
|
||||||
.ToArray();
|
.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1715,8 +1715,11 @@ public static class ItemAppraisalTextFormatter
|
||||||
_ => string.Empty,
|
_ => string.Empty,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary><c>AppraisalSystem::SkillToString @ 0x005B4A30</c>.</summary>
|
/// <summary><c>AppraisalSystem::SkillToString @ 0x005B4A30</c> — retail
|
||||||
private static string SkillName(int skill) => skill switch
|
/// skill-id -> 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",
|
1 => "Axe",
|
||||||
2 => "Bow",
|
2 => "Bow",
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,10 @@ public static class LayoutImporter
|
||||||
var w = DatWidgetFactory.Create(info, resolve, datFont, fontResolve, stringResolve);
|
var w = DatWidgetFactory.Create(info, resolve, datFont, fontResolve, stringResolve);
|
||||||
if (w is null) return null; // Type-12 style prototype — skip
|
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;
|
if (info.Id != 0) byId[info.Id] = w;
|
||||||
|
|
||||||
// Behavioral widgets that draw their full appearance + reproduce their dat
|
// 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);
|
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
|
// UIElement::SetState @ 0x00464E70 propagates a state's id only after the
|
||||||
// child tree exists. Re-applying the imported default here gives retained
|
// child tree exists. Re-applying the imported default here gives retained
|
||||||
|
|
|
||||||
|
|
@ -129,10 +129,15 @@ internal sealed class RetailConfirmationDialogView : IRetailDialogView
|
||||||
|
|
||||||
private void SizeAndCenter()
|
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.Left = 0f;
|
||||||
Root.Top = 0f;
|
Root.Top = 0f;
|
||||||
Root.Width = _host.Width;
|
Root.Width = space.X;
|
||||||
Root.Height = _host.Height;
|
Root.Height = space.Y;
|
||||||
_popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f);
|
_popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f);
|
||||||
_popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f);
|
_popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,9 @@ public static class RetailDialogProperty
|
||||||
public const uint AcceptLabel = 0x90u;
|
public const uint AcceptLabel = 0x90u;
|
||||||
public const uint RejectLabel = 0x91u;
|
public const uint RejectLabel = 0x91u;
|
||||||
public const uint ConfirmationResult = 0x92u;
|
public const uint ConfirmationResult = 0x92u;
|
||||||
|
public const uint TextInputAcceptLabel = 0x9Au;
|
||||||
|
public const uint TextInputRejectLabel = 0x9Bu;
|
||||||
|
public const uint TextInputResult = 0x9Cu;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// When true, <c>Dialog::SetData @ 0x00476BE0</c> sets UIElement boolean
|
/// When true, <c>Dialog::SetData @ 0x00476BE0</c> sets UIElement boolean
|
||||||
/// attribute <c>0x40</c>. The Keystone-owned attribute name is unavailable.
|
/// attribute <c>0x40</c>. The Keystone-owned attribute name is unavailable.
|
||||||
|
|
@ -105,11 +108,16 @@ public sealed class RetailDialogData
|
||||||
return clone;
|
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)
|
public static RetailDialogData Confirmation(string message)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(message);
|
ArgumentNullException.ThrowIfNull(message);
|
||||||
return new RetailDialogData()
|
return new RetailDialogData()
|
||||||
.Set(RetailDialogProperty.Type, RetailDialogType.Confirmation)
|
.Set(RetailDialogProperty.Type, RetailDialogType.Confirmation)
|
||||||
|
.Set(RetailDialogProperty.ElementAttribute40, true)
|
||||||
.Set(RetailDialogProperty.Message, message);
|
.Set(RetailDialogProperty.Message, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,4 +131,21 @@ public sealed class RetailDialogData
|
||||||
.Set(RetailDialogProperty.ElementAttribute40, true)
|
.Set(RetailDialogProperty.ElementAttribute40, true)
|
||||||
.Set(RetailDialogProperty.Message, message);
|
.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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ public sealed class RetailDialogFactory : IDisposable
|
||||||
public required RetailDialogData Data { get; init; }
|
public required RetailDialogData Data { get; init; }
|
||||||
public required uint Context { get; init; }
|
public required uint Context { get; init; }
|
||||||
public required uint QueueKey { get; init; }
|
public required uint QueueKey { get; init; }
|
||||||
|
public required ulong Sequence { get; init; }
|
||||||
public Action<RetailDialogData>? Callback { get; init; }
|
public Action<RetailDialogData>? Callback { get; init; }
|
||||||
public IRetailDialogView? View { get; set; }
|
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> _activeQueued = new();
|
||||||
private readonly Dictionary<uint, DialogInfo> _activeNonQueued = new();
|
private readonly Dictionary<uint, DialogInfo> _activeNonQueued = new();
|
||||||
private readonly Dictionary<uint, LinkedList<DialogInfo>> _pending = new();
|
private readonly Dictionary<uint, LinkedList<DialogInfo>> _pending = new();
|
||||||
|
private readonly LinkedList<DialogInfo> _retryable = new();
|
||||||
private readonly List<DialogInfo> _openOrder = new();
|
private readonly List<DialogInfo> _openOrder = new();
|
||||||
private uint _globalContext;
|
private uint _globalContext;
|
||||||
|
private ulong _globalSequence;
|
||||||
private bool _resetting;
|
private bool _resetting;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
|
|
@ -51,6 +54,8 @@ public sealed class RetailDialogFactory : IDisposable
|
||||||
|
|
||||||
public int PendingCount => _pending.Values.Sum(static queue => queue.Count);
|
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>
|
/// <summary>Exact root-element switch from <c>CreateDialog_ @ 0x00477AD0</c>.</summary>
|
||||||
public static uint RootElementId(RetailDialogType type)
|
public static uint RootElementId(RetailDialogType type)
|
||||||
=> type switch
|
=> type switch
|
||||||
|
|
@ -87,25 +92,40 @@ public sealed class RetailDialogFactory : IDisposable
|
||||||
Data = ownedData,
|
Data = ownedData,
|
||||||
Context = context,
|
Context = context,
|
||||||
QueueKey = queueKey,
|
QueueKey = queueKey,
|
||||||
|
Sequence = NextSequence(),
|
||||||
Callback = callback,
|
Callback = callback,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (queueKey == NonQueuedKey)
|
if (queueKey == NonQueuedKey)
|
||||||
{
|
{
|
||||||
_activeNonQueued.Add(context, info);
|
_activeNonQueued.Add(context, info);
|
||||||
CreateDialog(info);
|
if (!TryCreateDialog(info))
|
||||||
|
{
|
||||||
|
_activeNonQueued.Remove(context);
|
||||||
|
QueueRetry(info);
|
||||||
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_activeQueued.TryGetValue(queueKey, out DialogInfo? current))
|
if (!_activeQueued.TryGetValue(queueKey, out DialogInfo? current))
|
||||||
{
|
{
|
||||||
|
if (HasRetry(queueKey) && !IsPriority(info))
|
||||||
|
{
|
||||||
|
PendingQueue(queueKey).AddLast(info);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
_activeQueued.Add(queueKey, info);
|
_activeQueued.Add(queueKey, info);
|
||||||
CreateDialog(info);
|
if (!TryCreateDialog(info))
|
||||||
|
{
|
||||||
|
_activeQueued.Remove(queueKey);
|
||||||
|
QueueRetry(info);
|
||||||
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
LinkedList<DialogInfo> queue = PendingQueue(queueKey);
|
LinkedList<DialogInfo> queue = PendingQueue(queueKey);
|
||||||
if (!ownedData.GetBoolean(RetailDialogProperty.Priority))
|
if (!IsPriority(info))
|
||||||
{
|
{
|
||||||
queue.AddLast(info);
|
queue.AddLast(info);
|
||||||
UpdatePendingDialogDisplays();
|
UpdatePendingDialogDisplays();
|
||||||
|
|
@ -118,7 +138,15 @@ public sealed class RetailDialogFactory : IDisposable
|
||||||
Suspend(current);
|
Suspend(current);
|
||||||
queue.AddFirst(current);
|
queue.AddFirst(current);
|
||||||
_activeQueued[queueKey] = info;
|
_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;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,6 +176,26 @@ public sealed class RetailDialogFactory : IDisposable
|
||||||
return MakeDialog(data, callback: null);
|
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>
|
/// <summary>
|
||||||
/// Retail <c>CloseDialog @ 0x00478160</c>. The context can identify an active
|
/// 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.
|
/// 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;
|
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;
|
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()
|
public void Tick()
|
||||||
{
|
{
|
||||||
|
RetryFailedDialogs();
|
||||||
foreach (DialogInfo info in _openOrder.ToArray())
|
foreach (DialogInfo info in _openOrder.ToArray())
|
||||||
info.View?.Tick();
|
{
|
||||||
|
if (info.View is { } view)
|
||||||
|
{
|
||||||
|
_host.BringToFront(view.Root);
|
||||||
|
view.Tick();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -218,6 +322,7 @@ public sealed class RetailDialogFactory : IDisposable
|
||||||
DialogInfo[] infos = _activeNonQueued.Values
|
DialogInfo[] infos = _activeNonQueued.Values
|
||||||
.Concat(_activeQueued.Values)
|
.Concat(_activeQueued.Values)
|
||||||
.Concat(_pending.Values.SelectMany(static queue => queue))
|
.Concat(_pending.Values.SelectMany(static queue => queue))
|
||||||
|
.Concat(_retryable)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToArray();
|
.ToArray();
|
||||||
if (infos.Length == 0)
|
if (infos.Length == 0)
|
||||||
|
|
@ -229,6 +334,7 @@ public sealed class RetailDialogFactory : IDisposable
|
||||||
_activeNonQueued.Clear();
|
_activeNonQueued.Clear();
|
||||||
_activeQueued.Clear();
|
_activeQueued.Clear();
|
||||||
_pending.Clear();
|
_pending.Clear();
|
||||||
|
_retryable.Clear();
|
||||||
foreach (DialogInfo info in infos)
|
foreach (DialogInfo info in infos)
|
||||||
{
|
{
|
||||||
try { DialogDone(info); }
|
try { DialogDone(info); }
|
||||||
|
|
@ -263,6 +369,14 @@ public sealed class RetailDialogFactory : IDisposable
|
||||||
return _globalContext;
|
return _globalContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ulong NextSequence()
|
||||||
|
{
|
||||||
|
_globalSequence++;
|
||||||
|
if (_globalSequence == 0uL)
|
||||||
|
_globalSequence++;
|
||||||
|
return _globalSequence;
|
||||||
|
}
|
||||||
|
|
||||||
private LinkedList<DialogInfo> PendingQueue(uint queueKey)
|
private LinkedList<DialogInfo> PendingQueue(uint queueKey)
|
||||||
{
|
{
|
||||||
if (_pending.TryGetValue(queueKey, out LinkedList<DialogInfo>? queue))
|
if (_pending.TryGetValue(queueKey, out LinkedList<DialogInfo>? queue))
|
||||||
|
|
@ -272,19 +386,35 @@ public sealed class RetailDialogFactory : IDisposable
|
||||||
return queue;
|
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(
|
throw new NotSupportedException(
|
||||||
$"Retail dialog type {(uint)type} does not have a ported presenter yet.");
|
$"Retail dialog type {(uint)type} does not have a ported presenter yet.");
|
||||||
|
}
|
||||||
|
|
||||||
ImportedLayout layout = _createLayout(type)
|
ImportedLayout layout = _createLayout(type)
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
$"Retail dialog catalog could not create type {(uint)type}.");
|
$"Retail dialog catalog could not create type {(uint)type}.");
|
||||||
IRetailDialogView view = type switch
|
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(
|
_ => new RetailConfirmationDialogView(
|
||||||
_host, layout, info.Data, info.Context,
|
_host, layout, info.Data, info.Context,
|
||||||
context => CloseDialog(context)),
|
context => CloseDialog(context)),
|
||||||
|
|
@ -294,8 +424,19 @@ public sealed class RetailDialogFactory : IDisposable
|
||||||
_host.BringToFront(view.Root);
|
_host.BringToFront(view.Root);
|
||||||
_openOrder.Add(info);
|
_openOrder.Add(info);
|
||||||
_host.Modal = view.Root;
|
_host.Modal = view.Root;
|
||||||
|
view.Tick();
|
||||||
UpdatePendingDialogDisplays();
|
UpdatePendingDialogDisplays();
|
||||||
DialogOpened?.Invoke(info.Context);
|
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)
|
private void Suspend(DialogInfo info)
|
||||||
|
|
@ -349,6 +490,9 @@ public sealed class RetailDialogFactory : IDisposable
|
||||||
if (_activeQueued.ContainsKey(queueKey))
|
if (_activeQueued.ContainsKey(queueKey))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if (TryActivateRetry(queueKey))
|
||||||
|
return;
|
||||||
|
|
||||||
if (!_pending.TryGetValue(queueKey, out LinkedList<DialogInfo>? queue)
|
if (!_pending.TryGetValue(queueKey, out LinkedList<DialogInfo>? queue)
|
||||||
|| queue.First is null)
|
|| queue.First is null)
|
||||||
return;
|
return;
|
||||||
|
|
@ -358,7 +502,115 @@ public sealed class RetailDialogFactory : IDisposable
|
||||||
if (queue.Count == 0)
|
if (queue.Count == 0)
|
||||||
_pending.Remove(queueKey);
|
_pending.Remove(queueKey);
|
||||||
_activeQueued.Add(queueKey, next);
|
_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()
|
private void UpdatePendingDialogDisplays()
|
||||||
|
|
|
||||||
108
src/AcDream.App/UI/Layout/RetailMessageDialogView.cs
Normal file
108
src/AcDream.App/UI/Layout/RetailMessageDialogView.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -95,10 +95,15 @@ internal sealed class RetailWaitDialogView : IRetailDialogView
|
||||||
|
|
||||||
private void SizeAndCenter()
|
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.Left = 0f;
|
||||||
Root.Top = 0f;
|
Root.Top = 0f;
|
||||||
Root.Width = _host.Width;
|
Root.Width = space.X;
|
||||||
Root.Height = _host.Height;
|
Root.Height = space.Y;
|
||||||
_popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f);
|
_popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f);
|
||||||
_popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f);
|
_popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,15 @@ public class UiDatElement : UiElement, IUiDatStateful
|
||||||
/// <summary>Label color (default white).</summary>
|
/// <summary>Label color (default white).</summary>
|
||||||
public Vector4 LabelColor { get; set; } = Vector4.One;
|
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,
|
/// <summary>Retail LayoutDesc property <c>0x21</c> (two-pass glyph outline,
|
||||||
/// <c>UIElement_Text::SetOutline @0x0046a81c</c>). Seeded in the ctor from the
|
/// <c>UIElement_Text::SetOutline @0x0046a81c</c>). Seeded in the ctor from the
|
||||||
/// element's effective-default state, same as <see cref="UiText.Outline"/>
|
/// element's effective-default state, same as <see cref="UiText.Outline"/>
|
||||||
|
|
@ -205,6 +214,56 @@ public class UiDatElement : UiElement, IUiDatStateful
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public uint? RuntimeImageTexture { get; set; }
|
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)
|
protected override void OnDraw(UiRenderContext ctx)
|
||||||
{
|
{
|
||||||
if (MediaVisible && RuntimeImageTexture is uint runtimeTexture)
|
if (MediaVisible && RuntimeImageTexture is uint runtimeTexture)
|
||||||
|
|
@ -221,7 +280,7 @@ public class UiDatElement : UiElement, IUiDatStateful
|
||||||
0f,
|
0f,
|
||||||
1f,
|
1f,
|
||||||
1f,
|
1f,
|
||||||
Vector4.One);
|
Tint);
|
||||||
}
|
}
|
||||||
DrawLabel(ctx);
|
DrawLabel(ctx);
|
||||||
return;
|
return;
|
||||||
|
|
@ -233,10 +292,14 @@ public class UiDatElement : UiElement, IUiDatStateful
|
||||||
var (tex, tw, th) = _resolve(file);
|
var (tex, tw, th) = _resolve(file);
|
||||||
if (tex != 0 && tw != 0 && th != 0)
|
if (tex != 0 && tw != 0 && th != 0)
|
||||||
{
|
{
|
||||||
// Normal → TILE at native size on both axes (UV-repeat; GL_REPEAT-wrapped UI
|
// TILE at native size on both axes (UV-repeat; GL_REPEAT-wrapped
|
||||||
// texture), matching ImgTex::TileCSI. Overlay/Alphablend use the same blit (the
|
// UI texture) — retail's Graphic::Draw/Graphic::PutImage
|
||||||
// sprite shader already alpha-blends). No Stretch mode exists in DrawModeType.
|
// (0x00693b20/0x00693a30) copy-or-tile blit; NOT ImgTex::TileCSI,
|
||||||
ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
|
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ using AcDream.Core.Selection;
|
||||||
using AcDream.Core.Spells;
|
using AcDream.Core.Spells;
|
||||||
using AcDream.Runtime;
|
using AcDream.Runtime;
|
||||||
using AcDream.Runtime.Gameplay;
|
using AcDream.Runtime.Gameplay;
|
||||||
|
using AcDream.Runtime.Session;
|
||||||
using AcDream.Content;
|
using AcDream.Content;
|
||||||
using AcDream.Core.Input;
|
using AcDream.Core.Input;
|
||||||
using AcDream.UI.Abstractions;
|
using AcDream.UI.Abstractions;
|
||||||
|
|
@ -369,6 +370,53 @@ public sealed record KeyboardRuntimeBindings(
|
||||||
InputDispatcher? Dispatcher,
|
InputDispatcher? Dispatcher,
|
||||||
string KeyBindingsFilePath);
|
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 -> <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(
|
public sealed record RetailUiRuntimeBindings(
|
||||||
UiHost Host,
|
UiHost Host,
|
||||||
RetailUiAssets Assets,
|
RetailUiAssets Assets,
|
||||||
|
|
@ -395,7 +443,10 @@ public sealed record RetailUiRuntimeBindings(
|
||||||
BufferedUiRegistry? Plugins,
|
BufferedUiRegistry? Plugins,
|
||||||
RetailUiPersistenceBindings? Persistence,
|
RetailUiPersistenceBindings? Persistence,
|
||||||
RetailUiProbeBindings Probe,
|
RetailUiProbeBindings Probe,
|
||||||
KeyboardRuntimeBindings? Keyboard = null);
|
KeyboardRuntimeBindings? Keyboard = null,
|
||||||
|
CharacterSelectionRuntimeBindings? CharacterSelection = null,
|
||||||
|
// Campaign CC slice CC4: sibling of CharacterSelection above.
|
||||||
|
CharacterCreationRuntimeBindings? CharacterCreation = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Composition owner for the production retained gameplay UI. GameWindow supplies
|
/// Composition owner for the production retained gameplay UI. GameWindow supplies
|
||||||
|
|
@ -417,6 +468,8 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
private UiShortcutDigitGraphics? _shortcutDigitGraphics;
|
private UiShortcutDigitGraphics? _shortcutDigitGraphics;
|
||||||
private ItemCooldownUiController? _itemCooldownController;
|
private ItemCooldownUiController? _itemCooldownController;
|
||||||
private VividTargetIndicatorController? _vividTargetIndicator;
|
private VividTargetIndicatorController? _vividTargetIndicator;
|
||||||
|
private CharacterManagementUiMountCoordinator? _characterManagementMount;
|
||||||
|
private CharacterCreationUiMountCoordinator? _characterCreationMount;
|
||||||
private IDisposable? _characterSheetSubscription;
|
private IDisposable? _characterSheetSubscription;
|
||||||
private ResourceShutdownTransaction? _shutdown;
|
private ResourceShutdownTransaction? _shutdown;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
@ -483,6 +536,10 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
MountVendor();
|
MountVendor();
|
||||||
MountSecureTrade();
|
MountSecureTrade();
|
||||||
MountItemCooldowns();
|
MountItemCooldowns();
|
||||||
|
ConfigureCharacterManagement();
|
||||||
|
_characterManagementMount?.Tick();
|
||||||
|
ConfigureCharacterCreation();
|
||||||
|
_characterCreationMount?.Tick();
|
||||||
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
|
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
|
||||||
BindToolbarPanelButtons();
|
BindToolbarPanelButtons();
|
||||||
SyncToolbarWindowButtons();
|
SyncToolbarWindowButtons();
|
||||||
|
|
@ -577,6 +634,134 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
public VendorUiController? VendorController { get; private set; }
|
public VendorUiController? VendorController { get; private set; }
|
||||||
public OptionsPanelController? OptionsPanelController { get; private set; }
|
public OptionsPanelController? OptionsPanelController { get; private set; }
|
||||||
public SocialPanelController? SocialPanelController { 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)
|
public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings)
|
||||||
{
|
{
|
||||||
|
|
@ -622,6 +807,10 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
ExternalContainerController?.Tick();
|
ExternalContainerController?.Tick();
|
||||||
SocialPanelController?.Tick();
|
SocialPanelController?.Tick();
|
||||||
_itemCooldownController?.Tick();
|
_itemCooldownController?.Tick();
|
||||||
|
_characterManagementMount?.Tick();
|
||||||
|
CharacterManagementController?.Tick();
|
||||||
|
_characterCreationMount?.Tick();
|
||||||
|
CharacterCreationController?.Tick();
|
||||||
DialogFactory?.Tick();
|
DialogFactory?.Tick();
|
||||||
Host.Tick(deltaSeconds);
|
Host.Tick(deltaSeconds);
|
||||||
_automation?.Tick(deltaSeconds);
|
_automation?.Tick(deltaSeconds);
|
||||||
|
|
@ -788,6 +977,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
CharacterManagementController?.ResetSession();
|
||||||
DialogFactory?.Reset();
|
DialogFactory?.Reset();
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|
@ -2437,7 +2627,12 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
// default, installed at startup by the graphical host;
|
// default, installed at startup by the graphical host;
|
||||||
// fixture/headless mounts leave the catalog empty and the
|
// fixture/headless mounts leave the catalog empty and the
|
||||||
// controller falls back to the static preset ladder.
|
// 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);
|
resolutionDefault: Rendering.DisplayModeCatalog.DesktopResolution);
|
||||||
if (!configBound)
|
if (!configBound)
|
||||||
Console.WriteLine("[UI] options panel: Config tab rows did not bind.");
|
Console.WriteLine("[UI] options panel: Config tab rows did not bind.");
|
||||||
|
|
@ -3030,13 +3225,29 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
|
|
||||||
private void MountDialogFactory()
|
private void MountDialogFactory()
|
||||||
{
|
{
|
||||||
|
if (DialogFactory is not null)
|
||||||
|
return;
|
||||||
|
|
||||||
uint layoutId;
|
uint layoutId;
|
||||||
|
try
|
||||||
|
{
|
||||||
lock (_bindings.Assets.DatLock)
|
lock (_bindings.Assets.DatLock)
|
||||||
{
|
{
|
||||||
// DialogFactory::CreateDialog_ @ 0x00477AD0 resolves the shared
|
// DialogFactory::CreateDialog_ @ 0x00477AD0 resolves the shared
|
||||||
// catalog through GetDIDByEnum(2, 5). Each shown DialogInfo then
|
// catalog through GetDIDByEnum(2, 5). Each shown DialogInfo then
|
||||||
// creates a fresh type-specific root from that catalog.
|
// 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)
|
if (layoutId == 0u)
|
||||||
|
|
@ -3273,10 +3484,12 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
_bindings.Assets.ResolveSprite,
|
_bindings.Assets.ResolveSprite,
|
||||||
_bindings.Assets.Controls);
|
_bindings.Assets.Controls);
|
||||||
Host.Root.AddChild(element);
|
Host.Root.AddChild(element);
|
||||||
|
_bindings.Plugins.CompleteMount(panel, Host.Root, element);
|
||||||
Console.WriteLine($"[D.2b] plugin UI panel loaded: {panel.MarkupPath}");
|
Console.WriteLine($"[D.2b] plugin UI panel loaded: {panel.MarkupPath}");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
_bindings.Plugins.FailMount(panel);
|
||||||
Console.WriteLine($"[D.2b] plugin UI panel '{panel.MarkupPath}' failed to load: {ex.Message}");
|
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.");
|
"[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()
|
private void MountItemCooldowns()
|
||||||
{
|
{
|
||||||
ItemCooldownAssets? assets;
|
ItemCooldownAssets? assets;
|
||||||
|
|
@ -3710,7 +4188,12 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
() => _itemConfirmationController?.Dispose(),
|
() => _itemConfirmationController?.Dispose(),
|
||||||
() => _gameplayConfirmationController?.Dispose(),
|
() =>
|
||||||
|
{
|
||||||
|
_characterManagementMount?.Dispose();
|
||||||
|
_characterCreationMount?.Dispose();
|
||||||
|
_gameplayConfirmationController?.Dispose();
|
||||||
|
},
|
||||||
() => DialogFactory?.Dispose(),
|
() => DialogFactory?.Dispose(),
|
||||||
_panelUi.Dispose,
|
_panelUi.Dispose,
|
||||||
Host.Dispose);
|
Host.Dispose);
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,9 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
||||||
private readonly FaceSegment[] _faceSegments;
|
private readonly FaceSegment[] _faceSegments;
|
||||||
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
|
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
|
||||||
private readonly HashSet<uint> _availableStates = new();
|
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 _pressed;
|
||||||
private bool _pointerOver;
|
private bool _pointerOver;
|
||||||
private bool _selected;
|
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>
|
/// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary>
|
||||||
public Action? OnClick { get; set; }
|
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>
|
/// <summary>
|
||||||
/// Optional right-click handler (Campaign OP slice OP8's Configure Keyboard
|
/// Optional right-click handler (Campaign OP slice OP8's Configure Keyboard
|
||||||
/// screen: right-click a bound key button to erase that one binding —
|
/// screen: right-click a bound key button to erase that one binding —
|
||||||
|
|
@ -143,6 +153,50 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public uint? FaceFileOverride { get; set; }
|
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>
|
/// <summary>Additional left inset for left-aligned labels.</summary>
|
||||||
public float LabelOffsetX { get; set; } = 3f;
|
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>
|
/// Left for the paperdoll "Slots" caption that sits at the left edge, before the slots.</summary>
|
||||||
public LabelAlignment LabelAlign { get; set; } = LabelAlignment.Center;
|
public LabelAlignment LabelAlign { get; set; } = LabelAlignment.Center;
|
||||||
|
|
||||||
/// <summary>Label horizontal alignment options.</summary>
|
/// <summary>
|
||||||
public enum LabelAlignment { Center, Left }
|
/// GF-11c (Campaign CC gate round 1 Batch B): optional authored label
|
||||||
|
/// rectangle, LOCAL to this button. When a caption is LIFTED from a
|
||||||
|
/// DISTINCT Type-12 child that carries its own independent rect (e.g.
|
||||||
|
/// the Town page's per-marker name label, positioned below/beside its
|
||||||
|
/// marker rather than immediately right of it), <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 ToggleBehavior { get; }
|
||||||
public bool RolloverEnabled { get; }
|
public bool RolloverEnabled { get; }
|
||||||
|
|
@ -323,6 +443,21 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
||||||
foreach (FaceSegment segment in _faceSegments)
|
foreach (FaceSegment segment in _faceSegments)
|
||||||
AddAvailableStates(segment.Info);
|
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;
|
ToggleBehavior = info.TryGetEffectiveBool(0x0Bu, out bool toggle) && toggle;
|
||||||
RolloverEnabled = info.TryGetEffectiveBool(0x13u, out bool rollover) && rollover;
|
RolloverEnabled = info.TryGetEffectiveBool(0x13u, out bool rollover) && rollover;
|
||||||
HotClickEnabled = info.TryGetEffectiveBool(0x0Fu, out bool hotClick) && hotClick;
|
HotClickEnabled = info.TryGetEffectiveBool(0x0Fu, out bool hotClick) && hotClick;
|
||||||
|
|
@ -375,6 +510,22 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
||||||
foreach (FaceSegment segment in _faceSegments)
|
foreach (FaceSegment segment in _faceSegments)
|
||||||
DrawFace(ctx, ActiveFile(segment.Info), segment.Rect(Width, Height));
|
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
|
else
|
||||||
{
|
{
|
||||||
uint file = FaceFileOverride ?? ActiveFile(_mediaInfo);
|
uint file = FaceFileOverride ?? ActiveFile(_mediaInfo);
|
||||||
|
|
@ -388,18 +539,58 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
||||||
float faceWidth = FaceWidth > 0f ? FaceWidth : Width;
|
float faceWidth = FaceWidth > 0f ? FaceWidth : Width;
|
||||||
float faceHeight = FaceHeight > 0f ? FaceHeight : Height;
|
float faceHeight = FaceHeight > 0f ? FaceHeight : Height;
|
||||||
ctx.DrawSprite(tex, FaceLeft, FaceTop, faceWidth, faceHeight,
|
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)
|
if (Label is { Length: > 0 } label && LabelFont is { } lf)
|
||||||
{
|
{
|
||||||
float tx = LabelAlign == LabelAlignment.Left
|
// GF-11c: LabelBox null (every pre-existing button) reduces boxX/
|
||||||
? LabelOffsetX
|
// boxY to 0 and boxWidth/boxHeight to the button's own Width/
|
||||||
: (Width - lf.MeasureWidth(label)) * 0.5f; // centered (default)
|
// Height — byte-identical to the prior unconditional math.
|
||||||
float ty = (Height - lf.LineHeight) * 0.5f;
|
float boxX = LabelBox?.X ?? 0f;
|
||||||
ctx.DrawStringDat(lf, label, tx, ty, LabelColor, Outline, OutlineColor);
|
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
|
uint dragSprite = _itemDragAcceptance switch
|
||||||
|
|
@ -412,10 +603,154 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
||||||
{
|
{
|
||||||
var (tex, _, _) = _resolve(dragSprite);
|
var (tex, _, _) = _resolve(dragSprite);
|
||||||
if (tex != 0)
|
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)
|
private void DrawFace(UiRenderContext ctx, uint file, UiPixelRect rect)
|
||||||
{
|
{
|
||||||
if (file == 0 || rect.Width <= 0 || rect.Height <= 0)
|
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.
|
// first reflowed by its own four-edge retail layout policy.
|
||||||
ctx.DrawSprite(texture, rect.X0, rect.Y0, rect.Width, rect.Height,
|
ctx.DrawSprite(texture, rect.X0, rect.Y0, rect.Width, rect.Height,
|
||||||
0f, 0f, (float)rect.Width / textureWidth, (float)rect.Height / textureHeight,
|
0f, 0f, (float)rect.Width / textureWidth, (float)rect.Height / textureHeight,
|
||||||
Vector4.One);
|
Tint);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AddAvailableStates(ElementInfo mediaInfo)
|
private void AddAvailableStates(ElementInfo mediaInfo)
|
||||||
|
|
@ -551,6 +886,11 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
||||||
OnClick?.Invoke();
|
OnClick?.Invoke();
|
||||||
OnClickAt?.Invoke(e.Data1, e.Data2);
|
OnClickAt?.Invoke(e.Data1, e.Data2);
|
||||||
return OnClick is not null || OnClickAt is not null;
|
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:
|
case UiEventType.RightClick:
|
||||||
// S6 (2026-08-11 review): unlike Click (whose swallow-when-
|
// S6 (2026-08-11 review): unlike Click (whose swallow-when-
|
||||||
// disabled is pre-existing, harmless-by-construction behavior
|
// disabled is pre-existing, harmless-by-construction behavior
|
||||||
|
|
@ -626,13 +966,77 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
||||||
|
|
||||||
private void UpdateVisualState()
|
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,
|
Disabled: !Enabled,
|
||||||
Selected: _selected,
|
Selected: _selected,
|
||||||
RolloverEnabled: RolloverEnabled,
|
RolloverEnabled: RolloverEnabled,
|
||||||
Pressed: _pressed,
|
Pressed: _pressed,
|
||||||
PointerOver: _pointerOver));
|
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 -> 255,221,131 plus outline off -> on) or the Town
|
||||||
|
/// buttons' Normal-to-white caption swap (218,167,85 -> 255,255,255).
|
||||||
|
/// A button with a single authored color (the overwhelming majority)
|
||||||
|
/// never calls this, so <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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,19 @@ public abstract class UiElement
|
||||||
/// <summary>Human-readable name for debugging / FindByName.</summary>
|
/// <summary>Human-readable name for debugging / FindByName.</summary>
|
||||||
public string? Name { get; init; }
|
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();
|
private readonly Dictionary<string, UiCursorMedia> _stateCursors = new();
|
||||||
|
|
||||||
/// <summary>Retail MediaDescCursor entries keyed by UIStateId.ToString(), or "" for DirectState.</summary>
|
/// <summary>Retail MediaDescCursor entries keyed by UIStateId.ToString(), or "" for DirectState.</summary>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
|
|
||||||
namespace AcDream.App.UI;
|
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>
|
/// <summary>Single owner for named retained-window lifecycle and raise policy.</summary>
|
||||||
public RetailWindowManager WindowManager { get; }
|
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 ───────────────────────────────────────────────
|
// ── Device-level state ───────────────────────────────────────────────
|
||||||
public int MouseX { get; private set; }
|
public int MouseX { get; private set; }
|
||||||
public int MouseY { get; private set; }
|
public int MouseY { get; private set; }
|
||||||
|
|
@ -370,6 +499,21 @@ public sealed class UiRoot : UiElement
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Draw(UiRenderContext ctx)
|
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
|
// Render children (panels) sorted by z-order — modal last so it
|
||||||
// sits on top.
|
// sits on top.
|
||||||
|
|
@ -401,6 +545,7 @@ public sealed class UiRoot : UiElement
|
||||||
|
|
||||||
public void OnMouseMove(int x, int y)
|
public void OnMouseMove(int x, int y)
|
||||||
{
|
{
|
||||||
|
(x, y) = MapWindowToCanvas(x, y);
|
||||||
int dx = x - MouseX;
|
int dx = x - MouseX;
|
||||||
int dy = y - MouseY;
|
int dy = y - MouseY;
|
||||||
MouseX = x;
|
MouseX = x;
|
||||||
|
|
@ -552,6 +697,7 @@ public sealed class UiRoot : UiElement
|
||||||
|
|
||||||
public void OnMouseDown(UiMouseButton btn, int x, int y, uint flags = 0)
|
public void OnMouseDown(UiMouseButton btn, int x, int y, uint flags = 0)
|
||||||
{
|
{
|
||||||
|
(x, y) = MapWindowToCanvas(x, y);
|
||||||
MouseX = x; MouseY = y;
|
MouseX = x; MouseY = y;
|
||||||
UpdateButtonFlag(btn, down: true);
|
UpdateButtonFlag(btn, down: true);
|
||||||
_pressX = x; _pressY = y;
|
_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)
|
public void OnMouseUp(UiMouseButton btn, int x, int y, uint flags = 0)
|
||||||
{
|
{
|
||||||
|
(x, y) = MapWindowToCanvas(x, y);
|
||||||
MouseX = x; MouseY = y;
|
MouseX = x; MouseY = y;
|
||||||
UpdateButtonFlag(btn, down: false);
|
UpdateButtonFlag(btn, down: false);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,12 @@ public sealed class UiScrollbar : UiElement
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ScalarChanged is not null)
|
||||||
|
{
|
||||||
|
DrawVerticalScalar(ctx, resolve);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (Model is not { } m) return;
|
if (Model is not { } m) return;
|
||||||
|
|
||||||
// Track background — TILED vertically (retail DrawMode=Normal). The native track
|
// Track background — TILED vertically (retail DrawMode=Normal). The native track
|
||||||
|
|
@ -280,11 +286,60 @@ public sealed class UiScrollbar : UiElement
|
||||||
}
|
}
|
||||||
else
|
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(
|
private void DrawHorizontalModel(
|
||||||
UiRenderContext ctx,
|
UiRenderContext ctx,
|
||||||
Func<uint, (uint tex, int w, int h)> resolve,
|
Func<uint, (uint tex, int w, int h)> resolve,
|
||||||
|
|
@ -309,10 +364,35 @@ public sealed class UiScrollbar : UiElement
|
||||||
}
|
}
|
||||||
else
|
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>
|
/// <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,
|
private void DrawSprite(UiRenderContext ctx, Func<uint, (uint tex, int w, int h)> resolve,
|
||||||
uint id, float x, float y, float w, float h)
|
uint id, float x, float y, float w, float h)
|
||||||
|
|
@ -412,8 +492,17 @@ public sealed class UiScrollbar : UiElement
|
||||||
if (e.Type == UiEventType.MouseMove)
|
if (e.Type == UiEventType.MouseMove)
|
||||||
_hoveredButton = ButtonAt(e.Data1, e.Data2);
|
_hoveredButton = ButtonAt(e.Data1, e.Data2);
|
||||||
|
|
||||||
if (Horizontal && ScalarChanged is not null)
|
// Fix round F11: retail's chargen shade scrollbar (0x10000321) is
|
||||||
return OnScalarEvent(e);
|
// 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)
|
if (Horizontal && Model is not null)
|
||||||
return OnHorizontalModelEvent(e);
|
return OnHorizontalModelEvent(e);
|
||||||
|
|
@ -590,14 +679,77 @@ public sealed class UiScrollbar : UiElement
|
||||||
return false;
|
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)
|
if (resolve is not null && ThumbSprite != 0)
|
||||||
{
|
{
|
||||||
var (_, width, _) = resolve(ThumbSprite);
|
var (_, width, height) = resolve(ThumbSprite);
|
||||||
if (width > 0) return MathF.Min(width, Width);
|
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)
|
private void ChangeScalarPosition(float position)
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,27 @@ public sealed class UiText : UiElement, IUiDatStateful
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public float Padding { get; set; }
|
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
|
/// <summary>Retail property 0x20. Independent of horizontal/vertical
|
||||||
/// justification; false permits the normal multi-line layout path.</summary>
|
/// justification; false permits the normal multi-line layout path.</summary>
|
||||||
public bool OneLine { get; set; }
|
public bool OneLine { get; set; }
|
||||||
|
|
@ -555,7 +576,10 @@ public sealed class UiText : UiElement, IUiDatStateful
|
||||||
if (lines.Count == 0) return;
|
if (lines.Count == 0) return;
|
||||||
|
|
||||||
float lh = _lastLineHeight;
|
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 innerH = bottom - top;
|
||||||
float contentH = lines.Count * lh;
|
float contentH = lines.Count * lh;
|
||||||
|
|
||||||
|
|
@ -731,11 +755,37 @@ public sealed class UiText : UiElement, IUiDatStateful
|
||||||
float width = datFont is not null
|
float width = datFont is not null
|
||||||
? datFont.MeasureWidth(text)
|
? datFont.MeasureWidth(text)
|
||||||
: bitmapFont?.MeasureWidth(text) ?? 0f;
|
: bitmapFont?.MeasureWidth(text) ?? 0f;
|
||||||
if (Centered)
|
return ContentOffsetX(Width, Padding, MarginLeft, MarginRight, width, Centered, RightAligned);
|
||||||
return Math.Max(Padding, (Width - width) * 0.5f);
|
}
|
||||||
if (RightAligned)
|
|
||||||
return Math.Max(Padding, Width - Padding - width);
|
/// <summary>
|
||||||
return Padding;
|
/// 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)
|
public override bool OnEvent(in UiEvent e)
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<InternalsVisibleTo Include="AcDream.Bake.Tests" />
|
<InternalsVisibleTo Include="AcDream.Bake.Tests" />
|
||||||
|
<InternalsVisibleTo Include="AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
@ -24,6 +25,7 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\AcDream.Content\AcDream.Content.csproj" />
|
<ProjectReference Include="..\AcDream.Content\AcDream.Content.csproj" />
|
||||||
|
<ProjectReference Include="..\AcDream.Platform\AcDream.Platform.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
133
src/AcDream.Bake/BakeCommandLine.cs
Normal file
133
src/AcDream.Bake/BakeCommandLine.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,11 +9,28 @@ namespace AcDream.Bake;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class BakeOutputTransaction
|
public static class BakeOutputTransaction
|
||||||
{
|
{
|
||||||
|
internal const string StagingMarker = ".acdream-bake.";
|
||||||
|
|
||||||
public static TResult WriteValidateAndPublish<TResult>(
|
public static TResult WriteValidateAndPublish<TResult>(
|
||||||
string destinationPath,
|
string destinationPath,
|
||||||
Func<string, TResult> writeTemporary,
|
Func<string, TResult> writeTemporary,
|
||||||
Action<string, TResult> validateTemporary,
|
Action<string, TResult> validateTemporary,
|
||||||
CancellationToken cancellationToken = default)
|
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);
|
ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath);
|
||||||
ArgumentNullException.ThrowIfNull(writeTemporary);
|
ArgumentNullException.ThrowIfNull(writeTemporary);
|
||||||
|
|
@ -25,9 +42,7 @@ public static class BakeOutputTransaction
|
||||||
throw new InvalidOperationException("destination has no parent directory");
|
throw new InvalidOperationException("destination has no parent directory");
|
||||||
Directory.CreateDirectory(directory);
|
Directory.CreateDirectory(directory);
|
||||||
|
|
||||||
string temporaryPath = Path.Combine(
|
string temporaryPath = CreateStagingPath(fullDestination, Guid.NewGuid());
|
||||||
directory,
|
|
||||||
$".{Path.GetFileName(fullDestination)}.{Guid.NewGuid():N}.tmp");
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -36,6 +51,14 @@ public static class BakeOutputTransaction
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
validateTemporary(temporaryPath, result);
|
validateTemporary(temporaryPath, result);
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
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.
|
// Same-volume MoveFileEx/rename is the publication primitive.
|
||||||
// File.Replace additionally performs destination metadata/backup
|
// 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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
101
src/AcDream.Bake/BakeProgressJsonWriter.cs
Normal file
101
src/AcDream.Bake/BakeProgressJsonWriter.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/AcDream.Bake/BakeProgressReporter.cs
Normal file
34
src/AcDream.Bake/BakeProgressReporter.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/AcDream.Bake/BakePublicationGuard.cs
Normal file
80
src/AcDream.Bake/BakePublicationGuard.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ public sealed record BakeOptions
|
||||||
public HashSet<uint>? LandblockFilter { get; init; }
|
public HashSet<uint>? LandblockFilter { get; init; }
|
||||||
public int Threads { get; init; } = System.Environment.ProcessorCount;
|
public int Threads { get; init; } = System.Environment.ProcessorCount;
|
||||||
public CancellationToken CancellationToken { get; init; }
|
public CancellationToken CancellationToken { get; init; }
|
||||||
|
public IBakeProgressSink? Progress { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Compact result used by the full-scale gate and deterministic tests.</summary>
|
/// <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");
|
throw new ArgumentOutOfRangeException(nameof(options), "thread count must be positive");
|
||||||
|
|
||||||
options.CancellationToken.ThrowIfCancellationRequested();
|
options.CancellationToken.ThrowIfCancellationRequested();
|
||||||
|
options.Progress?.Started(PakFormat.CurrentBakeToolVersion, options.OutPath);
|
||||||
var totalStopwatch = Stopwatch.StartNew();
|
var totalStopwatch = Stopwatch.StartNew();
|
||||||
var report = BakeOutputTransaction.WriteValidateAndPublish(
|
var report = BakeOutputTransaction.WriteValidateAndPublish(
|
||||||
options.OutPath,
|
options.OutPath,
|
||||||
|
|
@ -112,6 +114,10 @@ public static class BakeRunner
|
||||||
};
|
};
|
||||||
|
|
||||||
PrintSummary(report, options.OutPath);
|
PrintSummary(report, options.OutPath);
|
||||||
|
options.Progress?.Completed(
|
||||||
|
report.Header.BakeToolVersion,
|
||||||
|
report.OutputBytes,
|
||||||
|
report.Failures);
|
||||||
return report;
|
return report;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -273,6 +279,8 @@ public static class BakeRunner
|
||||||
failures.Count,
|
failures.Count,
|
||||||
stopwatch.Elapsed,
|
stopwatch.Elapsed,
|
||||||
lastProgressReport,
|
lastProgressReport,
|
||||||
|
options.Progress,
|
||||||
|
"mesh",
|
||||||
batchStart + BatchSize >= ordinaryWork.Count &&
|
batchStart + BatchSize >= ordinaryWork.Count &&
|
||||||
envCatalog.UniqueGeometryCount == 0);
|
envCatalog.UniqueGeometryCount == 0);
|
||||||
}
|
}
|
||||||
|
|
@ -372,6 +380,8 @@ public static class BakeRunner
|
||||||
failures.Count,
|
failures.Count,
|
||||||
stopwatch.Elapsed,
|
stopwatch.Elapsed,
|
||||||
lastProgressReport,
|
lastProgressReport,
|
||||||
|
options.Progress,
|
||||||
|
"mesh",
|
||||||
batchStart + BatchSize >= envCatalog.Groups.Count);
|
batchStart + BatchSize >= envCatalog.Groups.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -571,6 +581,8 @@ public static class BakeRunner
|
||||||
failures.Count,
|
failures.Count,
|
||||||
collisionStopwatch.Elapsed,
|
collisionStopwatch.Elapsed,
|
||||||
lastProgressReport,
|
lastProgressReport,
|
||||||
|
options.Progress,
|
||||||
|
"collision",
|
||||||
final: false);
|
final: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -757,6 +769,8 @@ public static class BakeRunner
|
||||||
failures.Count,
|
failures.Count,
|
||||||
collisionStopwatch.Elapsed,
|
collisionStopwatch.Elapsed,
|
||||||
lastProgressReport,
|
lastProgressReport,
|
||||||
|
options.Progress,
|
||||||
|
"collision",
|
||||||
final: false);
|
final: false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -769,6 +783,8 @@ public static class BakeRunner
|
||||||
failures.Count,
|
failures.Count,
|
||||||
collisionStopwatch.Elapsed,
|
collisionStopwatch.Elapsed,
|
||||||
lastProgressReport,
|
lastProgressReport,
|
||||||
|
options.Progress,
|
||||||
|
"collision",
|
||||||
final: true);
|
final: true);
|
||||||
|
|
||||||
writer.Finish();
|
writer.Finish();
|
||||||
|
|
@ -910,6 +926,8 @@ public static class BakeRunner
|
||||||
int failures,
|
int failures,
|
||||||
TimeSpan elapsed,
|
TimeSpan elapsed,
|
||||||
Stopwatch lastProgressReport,
|
Stopwatch lastProgressReport,
|
||||||
|
IBakeProgressSink? progress,
|
||||||
|
string phase,
|
||||||
bool final)
|
bool final)
|
||||||
{
|
{
|
||||||
if (!final && lastProgressReport.Elapsed.TotalSeconds < 5)
|
if (!final && lastProgressReport.Elapsed.TotalSeconds < 5)
|
||||||
|
|
@ -920,11 +938,18 @@ public static class BakeRunner
|
||||||
using var process = Process.GetCurrentProcess();
|
using var process = Process.GetCurrentProcess();
|
||||||
process.Refresh();
|
process.Refresh();
|
||||||
long managedHeap = GC.GetGCMemoryInfo().HeapSizeBytes;
|
long managedHeap = GC.GetGCMemoryInfo().HeapSizeBytes;
|
||||||
Console.WriteLine(
|
long privateBytes = process.PrivateMemorySize64;
|
||||||
$"[{elapsed:hh\\:mm\\:ss}] extracted {done:N0}/{total:N0}, " +
|
BakeProgressReporter.Write(
|
||||||
$"failures={failures:N0}, elapsed={elapsed.TotalSeconds:F0}s, " +
|
Console.Out,
|
||||||
$"ETA={etaSeconds:F0}s, private={process.PrivateMemorySize64 / 1024.0 / 1024.0:F0}MB, " +
|
progress,
|
||||||
$"managed={managedHeap / 1024.0 / 1024.0:F0}MB");
|
phase,
|
||||||
|
done,
|
||||||
|
total,
|
||||||
|
failures,
|
||||||
|
elapsed,
|
||||||
|
etaSeconds,
|
||||||
|
privateBytes,
|
||||||
|
managedHeap);
|
||||||
lastProgressReport.Restart();
|
lastProgressReport.Restart();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue