From 0e71d3b829faf7e660596d3fc24b61bee856a1c7 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 15 Aug 2026 17:45:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(app):=20Campaign=20CC=20slice=20CC4=20?= =?UTF-8?q?=E2=80=94=20chargen=20screen=20shell=20+=20Heritage/Profession/?= =?UTF-8?q?Skills/Town=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mounts gmCharGenMainUI (enum 0x10000039, root 0x100003CC) via CharacterCreationUiController/CharacterCreationUiMountCoordinator, cloning CharacterManagementUiController's recipe. Master shell ports SetProgressState @0x004e7a10 (Olthoi tab-hide + redirect) and ListenToElementMessage @0x004e9450 (Back/Next/Finish/Help/Exit/Random nav) verbatim, with free tab navigation over all six pages. Heritage, Profession, Skills, and Town pages bind to CC3's RuntimeCharacterCreationState commands; Appearance and Summary mount as content-inert placeholders for CC6b/CC5. Live-DAT probing (CharacterCreationLiveDatTests) found two widget- mapping surprises the decomp's DynamicCast hints don't predict: the Profession slider's value field imports as an editable UiField (wired for direct numeric entry), and the avail/health/stamina/mana/credits displays author as UIElement_Button hosts whose Type-12 value child is swallowed by UiButton.ConsumesDatChildren — substituted with the button's own Label. No new DatWidgetFactory widget types were needed. Threads the installed DAT's real ChargenOptions into Runtime via the new RuntimeCharacterCreationState.InstallOptions, called from ContentEffectsAudioCompositionPhase.Compose (mirrors InstallSpellMetadata's pattern); headless keeps ChargenOptions.Empty unchanged. Wires CC3's F14 status-hook gap (ApplyCharacterCreated/ ApplyCreationFailed) to SessionStatusWriter for both graphical and headless hosts, and adds the CharacterCreation view/command seam through CurrentGameRuntimeAdapter and DeferredGameRuntimeStateCommands alongside CharacterSelection's existing shape. Register: AD-101/102/103, AP-212/213, TS-82 filed for the auto-gender- select interim default, the omitted ToD-account gate, the button-Label widget substitution, the Random-button approximation, the flat-listbox Skills simplification, and the Appearance/Summary placeholders. Runtime 1713/0 (was 1707), App 5117/13 skips (was 5101/6), Headless 165/0 unaffected, full solution Release build green. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 12 +- .../2026-08-15-character-creation-campaign.md | 2 +- .../ContentEffectsAudioComposition.cs | 30 + .../InteractionRetainedUiComposition.cs | 31 + .../InteractionUiRuntimeSources.cs | 61 ++ .../Net/LiveSessionRuntimeFactory.cs | 15 +- .../Runtime/CurrentGameRuntimeAdapter.cs | 206 +++++ src/AcDream.App/RuntimeOptions.cs | 8 + .../Layout/CharacterCreationHeritagePage.cs | 201 +++++ .../Layout/CharacterCreationProfessionPage.cs | 264 ++++++ .../UI/Layout/CharacterCreationSkillsPage.cs | 210 +++++ .../UI/Layout/CharacterCreationTownPage.cs | 121 +++ .../Layout/CharacterCreationUiController.cs | 650 ++++++++++++++ .../CharacterCreationUiMountCoordinator.cs | 99 +++ .../UI/Layout/ItemAppraisalTextFormatter.cs | 5 +- src/AcDream.App/UI/RetailUiRuntime.cs | 93 +- .../Hosting/HeadlessSessionHost.cs | 13 +- src/AcDream.Runtime/GameRuntime.cs | 2 + src/AcDream.Runtime/GameRuntimeViews.cs | 6 + .../Session/LiveSessionHost.cs | 15 +- .../Session/LiveSessionLifecycleHost.cs | 16 +- .../Session/RuntimeCharacterCreationState.cs | 35 +- .../ContentEffectsAudioCompositionTests.cs | 7 + .../Layout/CharacterCreationLiveDatTests.cs | 334 +++++++ .../CharacterCreationUiControllerTests.cs | 817 ++++++++++++++++++ .../RuntimeCharacterCreationStateTests.cs | 48 + .../Session/LiveSessionLifecycleHostTests.cs | 55 ++ 27 files changed, 3344 insertions(+), 12 deletions(-) create mode 100644 src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs create mode 100644 src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs create mode 100644 src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs create mode 100644 src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs create mode 100644 src/AcDream.App/UI/Layout/CharacterCreationUiController.cs create mode 100644 src/AcDream.App/UI/Layout/CharacterCreationUiMountCoordinator.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 913e52fc..df7934b4 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -63,7 +63,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 76 active rows (AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) +## 2. Adaptation (AD) — 79 active rows (AD-101..AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Heritage-page auto-gender-select interim default, the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -193,11 +193,14 @@ readiness/requeue adaptation. See | AD-97 | **Filed 2026-08-14 at Campaign LA slice LA7a (character-restore request tail).** Retail's `CharacterRestore` request (`0xF7D9`) is ≥16 bytes: `CPlayerSystem::RestoreCharacter @0x0055d760` is, in the PDB-paired binary, `push 0x008173B4; push 0x008173B4; push guid; call Proto_UI::SendAdminRestoreCharacter @0x00546cf0`, and the callee packs BOTH constant `PStringBase*` arguments (`PStringBase::Pack @0x004fc6f0` emits ≥4 bytes even empty). Binary Ninja renders the two pushes as an uninitialized `edx` local plus `this` — a rendering artifact around constant `0x008173B4` (all 3 of its other pseudo-C appearances sit in provably-broken decompiles), but the arguments are real. acdream sends the 8-byte guid-only form. What the two constant strings contain is unresolved (a live cdb `db poi(0x008173b4)` would settle it). | `src/AcDream.Core.Net/Messages/CharacterRestore.cs` (`BuildRequestBody`) | ACE reads only `ReadUInt32()` and ignores any tail (`CharacterHandler.cs:331-385`), and holtburger ships guid-only from a real client command path against ACE successfully — the tail is unread by every server we can test against, and packing two strings whose CONTENT we cannot verify would be a guess. | A byte-capture comparison against a real retail client differs from offset 8; a future server that validates the full retail shape would reject our 8-byte request. | `CPlayerSystem::RestoreCharacter @0x0055d760` (binary bytes, not the BN rendering); `Proto_UI::SendAdminRestoreCharacter @0x00546cf0`; `PStringBase::Pack @0x004fc6f0`; ACE `CharacterHandler.cs:331-385`; holtburger `character_selection.rs:79-82`; LA7a Opus review F1 (2026-08-14) | | AD-93 | **Filed 2026-08-13 at social gate round 2, item 5 (the refused-drop notice port).** Two narrow gaps in the `ServerSaysAttemptFailed @0x0058EAE0` port: (1) **latched-guid preference** — retail's 0x00A0 dispatcher (`@0x0055B342`) PREFERS `prevRequestObjectID` over the wire guid when picking the item to name; acdream's `InventoryTransactionState.OnMoveFailed` instead REQUIRES the wire guid to match the latch (unobservable against ACE, which always sends the request's own guid on 0x00A0, and it protects a stale latch from mislabeling an unrelated failure — acdream has no retail-style latch timeout). (2) **unlatched request kinds** — retail latches `IR_MOVE`/`IR_WIELD` too; acdream's kind enum has no Move/Wield rows because wields ride `AutoWieldController` outside the single-request gate, so a refused wield/3D-move shows only the generic `HandleFailureEvent` leg, never "The X can't be wielded/moved". | `src/AcDream.Core/Items/InventoryTransactionState.cs` (`OnMoveFailed`); `src/AcDream.Core/Chat/InventoryFailureMessages.cs` (`Compose`'s absent Move/Wield rows); `src/AcDream.App/UI/ItemInteractionController.cs` (`OnInventoryRequestFailed`) | The match requirement is the compensating guard for the missing latch timeout; adding Wield/Move kinds means routing those sends through the single-request gate they deliberately bypass today — a behavior change beyond this gate item. | Only observable against a server that sends 0x00A0 with a guid that differs from the request's item (ACE never does), or on a refused wield/move, which shows no "can't be wielded/moved" verb line where retail would show one. | `ACCWeenieObject::ServerSaysAttemptFailed @0x0058EAE0`; the 0x00A0 dispatcher `@0x0055B342`; `ACCWeenieObject::RecordRequest @0x0058C220`; `docs/research/2026-08-13-confirm-and-weenie-error-display.md` §2 | | AD-100 | **Filed 2026-08-15 at the Campaign CC CC2 review, finding F2 (unrequested `0xF643` handling).** When a `0xF643` (`CharGenVerificationResponse`) arrives with NO outstanding create/restore request, acdream DROPS the message with a once-per-session stderr log. Retail has no such gate: `Handle_CharGenVerificationResponse @0x0055E8B0` processes whatever arrives, discriminating create-vs-restore by its OWN persistent verification state (case 1 branches on `GetVerificationState() == PENDING` → new `CharacterIdentity` + `AddIdentity`, else unpacks into the existing identity at `slot`) — an unsolicited reply would be applied against whatever that state happens to be. acdream's transport-level latch (`PendingCharGenVerificationRequest`) is the equivalent discriminator, but when it is `None` there is no state to apply the reply against, so the honest move is drop-and-log rather than guessing a family. | `src/AcDream.Core.Net/WorldSession.cs` (the `CharGenVerificationResponse.ResponseOpcode` arm in `ProcessDatagram`; `_loggedUnexpectedCharGenVerificationResponse`) | Processing an unsolicited reply requires retail's persistent chargen verification state, which lives in CC3's Runtime owner, not the transport. Until then a reply with no outstanding request is either a server bug or a latch-lifecycle bug on our side — surfacing it in the log beats silently misrouting it to an arbitrary event. Pinned by `WorldSessionCharacterCreationTests.ResponseWithNoOutstandingRequest_IsDroppedAndNeverMisattributed`. | A server that sends a spontaneous/duplicate `0xF643` (ACE can double-send NameInUse — see the CC2 review's F3 note) has its second copy dropped here, where retail would re-process it. If CC3's verification gate ever needs retail's re-process semantics, this drop must move behind that owner's state. | `Handle_CharGenVerificationResponse @0x0055E8B0`; `CharGenState::GetVerificationState`; CC2 review F2 (2026-08-15) | +| AD-103 | **Filed 2026-08-15 at Campaign CC slice CC4 (chargen avail/health/stamina/mana displays and the Skills page credits meter).** Retail's `gmCGProfessionPage`/`gmCGSkillsPage` address these five values as independently-addressable `UIElement_Text` children (`DynamicCast(0xc)`) nested one level under a `UIElement_Button` container/badge (decomp ids `0x100002f1`/`0x100002f3` under `0x100003e2..e5` and `0x100003f9`). acdream's `UiButton.ConsumesDatChildren` swallows every dat child of a Type-1 element at import time (it treats them as label/face art, never as independently addressable overlay widgets — the same convention `UiMeter`'s explicit Type-12 carve-out exists to work around). Live-DAT probe evidence (`CharacterCreationLiveDatTests`) confirms this shape in the installed EoR build. acdream substitutes the CONTAINER button's own `.Label` for the swallowed child's text — same visible number, different addressable widget. | `src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs` (`_availableValue`/`_healthValue`/`_staminaValue`/`_manaValue`, `SetDisplay`); `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (`_credits`) | `UiButton.ConsumesDatChildren` is a structural, campaign-wide convention (shared with every other retained-UI button in the client, not special-cased for chargen); reproducing retail's literal nested-overlay-widget tree here would require the SAME `UiMeter`-style carve-out for every button that happens to author a Type-12 child, a wider change than this slice's scope. The composited pixel result (a number inside a bordered badge) is unchanged. | If a future consumer needs to address the value text independently of the badge button (e.g. per-glyph styling different from the button's label font), this substitution has no seam for it without extending `DatWidgetFactory`. | `gmCGProfessionPage::InitializePage @ 0x00482d50`; `gmCGProfessionPage::UpdateAttributeValues @ 0x00482450`; `gmCGSkillsPage::InitializePage @ 0x00481dd0`; `gmCGSkillsPage::UpdateCreditsMeter @ 0x004808f0`; `CharacterCreationLiveDatTests.ProfessionPage_HasTemplateButtonsSlidersAndDisplays`/`SkillsPage_HasListboxCreditsAndInfoPanes` | +| AD-102 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Heritage page's Viamontian button and the Town page's Sanamar button).** Retail gates BOTH controls behind `CPlayerSystem::AccountHasThroneOfDestiny`: `gmCGHeritagePage::ListenToElementMessage @ 0x00483860` shows `MakeToDWarningDialog` instead of selecting Viamontian (element `0x100003c3`) for a non-ToD account, and `gmCGTownPage::ListenToElementMessage @ 0x0047c480` does the same for Sanamar (element `0x1000040b`, `startArea` index 3 — also the reason `CharGenState::RandomizeStartArea`'s ToD-aware `RandInt(3 or 4)` bound exists). acdream's `ChargenOptions` (CC1) carries no account/DLC-ownership signal anywhere in the model, so both controls ship WITHOUT the gate — every installed heritage/town in `Options.HeritagesById`/`Options.StarterAreas` is always selectable, matching what a ToD-owning account would see. | `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`HeritageByButtonId[0x100003C3u]`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`StartAreaByButtonId[0x1000040Bu]`, `Randomize`) | ACE's server-side `CharacterCreate` handler never checks ToD ownership either (the field is purely a retail-client UI gate), so accepting the selection unconditionally never produces a request the emulator would reject; adding an account-ownership model to CC1's DAT-only `ChargenOptions` is out of this slice's scope and would need its own design (where does the "ToD owned" bit come from — account service, launcher config, a new env flag?). | None observable against ACE. A future retail-parity gate that specifically checks "does a non-ToD account get warned off Viamontian/Sanamar" will fail until an account-ownership signal exists to gate on. | `gmCGHeritagePage::ListenToElementMessage @ 0x00483860`; `gmCGTownPage::ListenToElementMessage @ 0x0047c480`; `gmCGTownPage::SetTown @ 0x0047c360`; `CharGenState::RandomizeStartArea` (DoRandom case 4, `RandInt(hasToD ? 4 : 3)`) | +| AD-101 | **Filed 2026-08-15 at Campaign CC slice CC4 (Heritage-page auto-gender-select).** Retail's Profession-page template application (`CharGenState::ApplyTemplate @ 0x005C5080`, reached from `TrySelectTemplate`) requires both heritage AND gender to already be selected. Retail's OWN gender controls (`0x100003a7`/`0x100003a8`) live on the Appearance page (`gmCGAppearancePage @ 0x0047de70`), which this slice deliberately mounts as an empty, content-inert placeholder — CC6b's explicit scope per the campaign's parallelism contract. Without SOME gender selection, the Profession/Skills/Town pages CC4 builds would be permanently unusable (every `SelectTemplate`/skill/town command silently refused by `RuntimeCharacterCreationState`'s heritage+gender gate) until CC6b lands. `CharacterCreationHeritagePage.Select` therefore auto-selects the chosen heritage's numerically-lowest `GendersByKey` entry immediately after a successful `SelectHeritage`, with no player-visible gender-choice UI this round. | `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Select`) | CC6b's real gender buttons are a strict superset of this behavior (an explicit player choice instead of an implicit default) and will make this row's auto-select unreachable/moot once wired — retire this row then. Until then, every heritage's genders differ only in appearance-option lists (never in attribute/skill/template data — CC1's model), so which gender is implicitly selected has no effect on any value CC4's pages read or write. | A heritage with per-gender TEMPLATE or SKILL differences (none exist in the installed DAT per CC1's gates) would silently commit to the wrong gender's data; a player who would have picked the other gender gets no chance to before Profession/Skills/Town become interactive. | `CharGenState::ApplyTemplate @ 0x005C5080`; `gmCGAppearancePage @ 0x0047de70` (gender buttons `0x100003a7`/`0x100003a8`, unbuilt this round); `RuntimeCharacterCreationState.TrySelectTemplate`'s heritage/gender gate | | AD-99 | **Filed 2026-08-15 at Campaign LA gate round 2 finding 1 (character-select Exit button).** On a confirmed Exit, acdream closes the client through the existing graceful window-close path (`d.Window.Close`, the same seam `GameplayInputCommandController`'s in-world Escape fallback already uses) instead of retail's real post-confirm behavior: `RecvNotice_CloseDialog`'s case-1 arm queues UI mode `0x10000009`, which `gmEpilogueUI::Register` claims — a brief epilogue/farewell screen — before the process actually terminates. The confirmation dialog itself (`MakeConfirmExitDialog`, its exact `ID_CharacterManagement_ConfirmExit` text, and the `m_confirmExitDialogContext != 0` re-entry guard) IS ported faithfully; only the post-confirm destination differs, the same shape as AD-74's Options-panel exit. | `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (`RequestExit`); `src/AcDream.App/UI/RetailUiRuntime.cs` (`CharacterSelectionRuntimeBindings.RequestExit`); `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs` (`d.Window.Close` binding) | acdream has no `gmEpilogueUI` port (out of scope this round); reusing the ONE existing graceful-shutdown seam keeps `disconnected`/`exited` status events firing through `GameWindow.OnClosing` → `CompleteShutdown` rather than inventing a second shutdown path, per explicit direction for this finding. | A user confirming Exit sees the window close immediately instead of retail's brief epilogue screen; a future feature wanting to reproduce that screen (or an intermediate "logged off, returned to character select" state) has no seam yet — same gap class as AD-44. | `gmCharacterManagementUI::MakeConfirmExitDialog @0x004ed250`; `RecvNotice_CloseDialog @0x004ed760` case 1; `gmEpilogueUI::Register(0x10000009)` @0x0047a680; `gmCharacterManagementUI::OnAction @0x004ed410` (Escape key, unported — button-only this round) | --- -## 3. Documented approximation (AP) — 147 active rows (AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 149 active rows (AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -387,9 +390,11 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-208 | **Filed 2026-08-15 at Campaign CC slice CC3.** Retail derives a PER-STYLE available-dye-color count for each clothing slot via `CharGenState::StoreColorInformation @ 0x005C44D0` (reading that specific style's own `ClothingTable`/`CloPaletteTemplate` palette list — different headgear styles can offer different numbers of dye choices) and clamps `headgearColor`/`shirtColor`/`trousersColor`/`footwearColor` against that per-style count in `SetHeadgearStyle`/`SetShirtStyle`/`SetTrousersStyle`/`SetFootwearStyle` (@0x005C5350/0x005C5480/0x005C55A0/0x005C56C0) and `ConstrainAllByGender @ 0x005C5B80`. `ChargenOptions`/`ChargenGenderOptions` (CC1) carry no per-style color-count data — only ONE shared `ClothingColors` list per gender. `RuntimeCharacterCreationState.TrySetAppearanceIndex`/`ConstrainAppearanceByGenderLocked` bound every color slot against that single shared list instead. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`AppearanceSlotCountLocked`, `ConstrainAppearanceByGenderLocked`) | Adding per-style color-count data to CC1's Core model requires a new DAT read (`CloPaletteTemplate`/`Style_CG` palette-template walk) that CC1's already-review-closed `ChargenTableReader` doesn't perform; the shared-list bound is a safe (never-narrower-than-necessary in the common case) stand-in until a future slice reads the real per-style table. | A clothing style whose real per-style color count is SMALLER than the shared gender-wide `ClothingColors` list lets the user pick a color index retail would have refused for that specific style — the resulting wire index may resolve to a different (or no) dye on a genuine retail-DAT-driven ACE/appearance consumer. | `CharGenState::StoreColorInformation @ 0x005C44D0`; `SetHeadgearStyle @ 0x005C5350`; `ConstrainAllByGender @ 0x005C5B80` | | AP-209 | **Filed 2026-08-15 at Campaign CC slice CC3. BRANCH TABLE ADDED at the CC3 review-fix round (F10) — the original filing cited only the ordinary-human enum id, omitting the heritage-dependent branches.** Retail's `classID` wire field is resolved via `DBObj::GetDIDByEnum(...) @ CharGenState::GetCharGenResult 0x005C4030` — a DAT DID category lookup that branches on THREE heritage-dependent enum ids (`0x005C42B5`-`0x005C438B`): `0x10000003` for ordinary heritages, `0x10000090` for Olthoi (heritage `0xc`), `0x10000091` for OlthoiAcid (heritage `0xd`), plus three admin-flag variants of the same three (`0x10000004`/`0x10000092`/`0x10000093`) when the create is admin-flagged. `AcDream.Core` has no DAT/Chorizite dependency (a CC1-established, review-closed constraint), so `RuntimeCharacterCreationState.BuildRequestLocked` sends a constant `0` regardless of heritage. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`BuildRequestLocked`) | ACE's `PlayerFactory.CreatePlayer` never reads `characterCreateInfo.ClassId` (`references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:155`, commented out) — the field has no observable server-side effect against the only connected target this campaign gates on. | A future non-ACE server that DOES validate `classID` would reject or misclassify every acdream-created character; a future slice that wires the real DID lookup must NOT default to the ordinary-heritage id for Olthoi/OlthoiAcid characters — this row is the marker (and the branch table) to revisit if that ever becomes a real target. | `CharGenState::GetCharGenResult @ 0x005C4030` (branch table `0x005C42B5`-`0x005C438B`); `DBObj::GetDIDByEnum`; `PlayerFactory.cs:154-155` | | AP-210 | **Filed 2026-08-15 at Campaign CC slice CC3.** Retail's `ApplyTemplate @ 0x005C5080` applies a chosen template's six attributes one at a time through the individually-guarded setters (`SetStrength(this, row.strength, 0)` … `SetSelf(this, row.self, 0)`), each of which can silently refuse to RAISE its value when `GetAbsRemainingCredits` for that specific attribute is exactly zero at the moment it runs — a narrow but real cross-attribute ordering effect when switching heritage/template leaves stale attribute values from a PRIOR selection still resident during the sequential apply. `RuntimeCharacterCreationState.ApplyTemplateLocked` instead assigns `_attributes = row.Attributes` as one atomic replacement. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`ApplyTemplateLocked`) | Every template row in the installed CharGen DAT is curated, self-consistent data (CC1's installed-DAT gates), so the guard is not expected to trip for any real heritage/template pair in isolation; the ordering effect only matters when switching directly between two heritages/templates with very different attribute totals, which is a corner case not yet gated by a connected test. | A rapid heritage-switch-then-template-switch sequence could theoretically leave an attribute at a value retail's sequential guard would have refused to reach; unreachable through this slice's own commands (heritage selection always re-derives the FULL budget before applying), but a future direct-attribute-manipulation caller bypassing `TrySelectHeritage`/`TrySelectTemplate` could differ from retail. | `CharGenState::ApplyTemplate @ 0x005C5080`; `CharGenState::SetStrength @ 0x005C4660` (representative of all six) | +| AP-213 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Skills page listbox).** Retail's `gmCGSkillsPage` sorts every skill into four buckets — Specialized, Trained, UseableUntrained, UnuseableUntrained — via `InsertEntrySorted @ 0x00480a40` and re-buckets on every level change through `UpdateSkillEntry @ 0x00480bf0`, giving each row a category-relative position instead of a fixed order. `CharacterCreationSkillsPage` instead builds ONE flat listbox, rows in ascending skill-id order, each showing `"{name}: {level} (T{trainedCost}/S{specializedCost})"`, with a single click-to-advance/double-click-to-retreat interaction replacing retail's separate per-row Increase/Decrease affordances (`IncreaseSkillLevel @ 0x00480ca0`/`DecreaseSkillLevel @ 0x00480d60`). | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (`RebuildRows`, `FormatSkillLabel`, `Advance`, `Retreat`) | The four-bucket sorted model is a pure presentation refinement (grouping/ordering, not a rules difference) — every skill's costs, current level, and the credits gate CC3's `RuntimeCharacterCreationState` enforces are byte-identical; a flat list surfaces the same information with less UI-layer code for this slice's scope. | A player scanning for "what's already Trained" has to read each row's own level text instead of finding it grouped at the top of a bucket — a discoverability/polish gap, not a correctness gap; a future slice wanting the exact retail grouping can layer it on top of the SAME `RuntimeCharacterCreationState` commands without touching Runtime. | `gmCGSkillsPage::InsertEntrySorted @ 0x00480a40`; `gmCGSkillsPage::UpdateSkillEntry @ 0x00480bf0`; `gmCGSkillsPage::IncreaseSkillLevel @ 0x00480ca0`; `gmCGSkillsPage::DecreaseSkillLevel @ 0x00480d60` | +| AP-212 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Random button, element `0x100003cb`).** `gmCharGenMainUI::DoRandom @ 0x004e7d70` dispatches per-page to `CharGenState::RandomizeHeritageGroup`/`RandomizeTemplate`/`RandomizeSkills`/`SetStartArea(RandInt(hasToD ? 4 : 3))` — none of which CC3's Runtime command surface exposes as a primitive. CC4's Random handler approximates the Heritage/Profession/Town cases with a UNIFORM pick over every valid option reachable through the page's own existing commands (`SelectHeritage`/`SelectTemplate`/`SelectStartArea`), and disables the button outright on Skills (no `RandomizeSkills` equivalent exists at all), Appearance (this round's placeholder), and Summary (the randomize-WARNING dialog is CC5's). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`OnRandom`, `ApplyProgressState`'s `_random.Enabled` gate); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs` (`Randomize`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`Randomize`) | Random is a convenience affordance, not a gate any create can fail without — every value it can produce is independently reachable (and independently retail-cited) through the page's own ordinary Select commands; a uniform distribution over "every DAT-installed option" is the closest available stand-in without porting three more retail algorithms this slice did not scope. | A retail-parity test that checks the STATISTICAL distribution of repeated Random clicks (not just "produces a valid selection") would find acdream's uniform-over-all-options distribution differs from retail's own (e.g. `RandomizeTemplate`'s exact weighting, or the ToD-account-gated 3-vs-4 town bound — see AD-102). Skills has no Random affordance at all until a `RandomizeSkills` port lands. | `gmCharGenMainUI::DoRandom @ 0x004e7d70`; `CharGenState::RandomizeHeritageGroup`; `CharGenState::RandomizeTemplate`; `CharGenState::RandomizeSkills`; `CharGenState::SetStartArea` random-bound call site | | AP-211 | **Filed 2026-08-15 at the Campaign CC slice CC3 review-fix round (F12).** `RuntimeCharacterCreationState.TryBeginFinish` refuses locally (`RuntimeCharacterCreationLocalRefusal.RosterFull`) when `rosterCount >= slotCount`, gating a Finish attempt against the account's CharacterSet slot cap. `gmCharGenMainUI::DoFinish @ 0x004E9170` itself has NO such check — the decomp shows only the name/credit/verification-state gates (see the row's own doc comment history). Retail instead enforces the slot cap ONE LAYER UP, in the char-select UI that ghosts/un-ghosts the Create button, not inside chargen's own Finish path — this campaign's plan doc records the finding as risk item 3 ("Slot cap is client-enforced only (ACE never checks on create) — honor `slotCount` like retail's UI did", `docs/plans/2026-08-15-character-creation-campaign.md` §Risks item 3) without a specific decomp citation for the UI-layer enforcement site (not yet located). ACE never checks the cap server-side either way. | `src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs` (`TryBeginFinish`, `RuntimeCharacterCreationLocalRefusal.RosterFull`) | A full roster still needs SOME refusal before the wire send — CC4's Create-button flow has not been built yet (no ghosted-button layer exists to enforce the cap earlier), so `TryBeginFinish` is the only chokepoint available today; ACE itself never validates the cap, so refusing one layer earlier than retail's own UI has no server-visible consequence. | If CC4 later adds the ghosted Create button matching retail's own enforcement layer, this row's gate becomes redundant defense-in-depth rather than the sole enforcement point — revisit whether to keep both or retire this one; until then, a caller that bypasses the ghosted button (a headless bot, a future scripted client) still gets a locally-refused Finish exactly where retail's UI would have blocked the click. | `gmCharGenMainUI::DoFinish @ 0x004E9170` (no slot-cap check present); `docs/plans/2026-08-15-character-creation-campaign.md` (Risks item 3) | -## 4. Temporary stopgap (TS) — 48 active rows (TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) +## 4. Temporary stopgap (TS) — 49 active rows (TS-82 filed 2026-08-15 at Campaign CC slice CC4 — the Appearance/Summary page roots mount empty and content-inert, reachable via free tab navigation, pending CC5/CC6a/CC6b; TS-81 filed 2026-08-12 at Campaign FA slice FA2 — the AllegianceLoginNotification chat-text gap, BN-mislabeled string symbols pending DAT lookup; TS-80 partially narrowed same slice — the fellowship-create shareXp wire mechanism now exists, the option-bit reader is still FA4 scope; TS-75..TS-80 filed and TS-73 NARROWED 2026-08-11 at Campaign OP slice OP4 — the Character tab's 50-row consumer wiring: TS-73 narrowed to `DisableMostWeatherEffects`/`PersistentAtDay` only (`ViewCombatTarget`/`DisableDistanceFog` now work via App-layer poll bindings, not `TrySetOption`'s own switch); TS-75 "Always Daylight Outdoors" has no day/night time-of-day force (and corrects the plan's own `ForcedDayGroupIndex` mechanism-mismatch citation — that field is the WEATHER-VARIETY selector, not a time-of-day force); TS-76 five Character-tab rows with no consumer surface at all (3D tooltips, side-by-side vitals, spell durations, advanced combat UI, stay-in-chat-mode); TS-77 "Filter Language" has no profanity-filter subsystem; TS-78 "Use Main Pack as Default" has no client-side preferred-container consumer; TS-79 Group D salvage/housing (no salvage UI, no housing subsystem); TS-80 "Share Fellowship Experience and Luminance" is client-sourced (needs the fellowship-CREATE packet field, not just the stored bit) and unaudited this slice; TS-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's "Use Mouse Turning Settings" macro sends `PlayerOption.UseMouseTurning` and persists its five client-local siblings, but acdream has no persistent mouse-turning camera MODE for the bit to drive; TS-73 filed 2026-08-11 at the Campaign OP OP1 review-fix round — `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged`'s local side-effect switch (MF-2) covers only the two `PlayerModule`-state-mutating cases (0x02/0x12 fellowship mutual exclusion); the four presentation-binding cases (weather/day/combat-target/fog) remain unmodeled, pre-anchored to Campaign OP OP4's Group B consumer binds (see the row below); TS-71 RETIRED 2026-08-11 at the same round — both remaining `SetCharacterOptions (0x01A1)` flush triggers (the 480 s auto-save timer, the pre-logoff flush) are now wired through `LiveSessionController`'s own tick/stop transaction (`ConfigureAutoSaveTick`/`ConfigurePreLogoffFlush`, wired once by `GameRuntime`'s constructor), matching the plan's stated target; TS-72 RETIRED 2026-08-11 at the Campaign OP OP2 rework (double-REJECT fix round) — the click-toggle bit math is now decomp-CONFIRMED against `UIOption_CheckboxBitfield64::ListenToElementMessage @0x00485AE0` (`BitUtils::SetBitsOnOrOff`: OR-in-on / AND-NOT-off, which was already correct) and `::Refresh @0x004859C0` (the checked-state predicate, which WAS wrong — the shipped code required ALL mask bits set; retail checks on ANY mask bit — and is now fixed to match); the widget is still not reachable by any user (Campaign OP slice OP5 wires it), but nothing about its own click/checked mechanism remains genuinely unverified, so the row is retired rather than rewritten; TS-70 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item E (#362) — `ClientCommandResponses.cs` now parses and renders all four named inbound GameEvents (`ChannelIndex 0x0149`, `ChannelList 0x0148`, `AvailableHouses 0x0271`, `AllegianceInfoResponse 0x027C`), each wired into `GameEventWiring.cs` and rendering retail-shaped `LogTextType 0x00` lines ported from the named-retail decomp (`Handle_Communication__ChannelIndex`/`ChannelList` @0x0057d0c0/@0x0057d230, `Handle_House__Recv_AvailableHouses` + `DisplayListOfCoords` @0x00585d50/@0x00585c20, `Handle_Allegiance__AllegianceInfoResponseEvent` @0x0056a1d0); the row's `@on`/`@off` mention was never itself missing a handler (both already resolve through the pre-existing `WeenieErrorWithString` registration) so nothing there needed a fix; TS-68/TS-69 filed 2026-08-09, Campaign CH slice CH4 — the deferred allegiance/house subcommand dispatchers, the three unported pure-local commands (day/log/render); TS-66/TS-67 filed and TS-29 retired 2026-08-08, Campaign A slice A5 — the region ambient system landed, so TS-29's ambient half is ported and its music half turned out to have nothing to port; TS-66 is the omitted `seen_outside` interior case and TS-67 the in-plane contribution weight. TS-64/TS-65 filed 2026-08-08, Campaign A slice A2 — TS-64 the two unimplemented retail sound preferences (unfocused-app silence, pan disable) plus the three enable bools; TS-65 the volume-squared quirk, applied on the ambient path where two lanes byte-confirmed it and deliberately NOT on the hook path where the pre-multiplying overload is unpinned. TS-62/TS-63 filed 2026-08-02, continuation-executor slice; TS-4 and TS-8 retired 2026-07-31; Campaign P's goal-enumerated physics stopgaps are now zero. TS-4's graph/flat Path-6 branches match retail's foot SetCollide/Adjusted and head CollisionNormal/Collided split with no BSP-layer sliding-normal write; TS-8's live 0x02C2 carries its complete StatMod through the canonical enchantment record and updates effective stats immediately. Campaign P P7 2026-07-30: TS-25 retired — outbound stance has shipped via RawState.CurrentStyle since #219; TS-24 re-argued to AD-57; TS-40 re-argued to AD-58; TS-35 retired at P5; earlier same campaign: TS-1/TS-5/TS-23/TS-46 retired by ports; TS-23 retired 2026-07-30 at Campaign P Slice P3 — every mover-flags call site (local player world-entry ×2, remote DR sweep ×2, remote teleport, ordinary movers) now ORs in the mover's real PK/PKLite/Impenetrable `ObjectInfoState` bits via the new `ClientObjectTable`-backed `EntityCollisionFlagsExt.ResolveMoverPvpState` — **narrative corrected 2026-08-03 (#297): "real" only became true at #297. Until then the bits existed but the source `PublicWeenieBitfield` was frozen at CreateObject, so every one of those sites read a stale value for the whole session. The site enumeration is also incomplete: `RuntimeSetPositionMoverPreparation.cs:183-188` is a SEVENTH mover-flags site that decodes `record.Snapshot.ObjectDescriptionFlags` directly rather than calling `ResolveMoverPvpState`, and it also derives `ObjectInfoState.IsPlayer` from the PWD bit, contradicting `EntityCollisionFlags.cs:119-123`'s claim that every site uses a GUID-prefix heuristic. See AP-134.** — and `PlayerWeenie.JumpStaminaCost`'s `pk` parameter reads the real `PlayerKillerStatus`/`LastPkAttackTimestamp` pair against a 20-second window instead of a hardcoded `false`; the non-PK invariant (every ACE default-created character) is bit-identical to the pre-P3 value since `ResolveMoverPvpState` and the PK-timer predicate both resolve to a no-op for `PublicWeenieBitfield` absent/0; TS-46 retired 2026-07-30 at Campaign P Slice P3 — the Setup's verbatim ≤2-sphere list (`CPhysicsObj::transition` 0x00512dc0 → `SPHEREPATH::init_sphere` 0x0050c670) now seeds the sweep for the local player, remote dead-reckoning, and ordinary movers alike, replacing the two-scalar (radius, height) capsule reconstruction; remote/ordinary step-up/step-down are now Setup-derived (`CPartArray::GetStepUpHeight`/`GetStepDownHeight`, 0x005180d0/0x005180f0, ×ObjScale) instead of a hardcoded 0.4 m, closing both residuals the row named; TS-5 retired 2026-07-30 at Campaign P Slice P1 — real burden-gated CanJump + real JumpStaminaCost, both decomp-verbatim; TS-1 retired 2026-07-30 at Campaign P Slice P2 — the row was stale; the EdgeSlide → PrecipiceSlide/CliffSlide chain is already a real, tested port; TS-57..TS-61 filed 2026-07-29 during Campaign N — no outbound RejectRetransmit; TS-27 narrowed same slice to the inbound direction) + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -400,6 +405,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | TS-78 | "Use Main Pack as Default for Picking Up Items" (`PlayerOption MainPackPreferred`) has no acdream consumer — retail's `CPlayerSystem::PlaceInBackpack @0x0055d8c0` chooses which container a picked-up item lands in client-side; acdream's pickup path (`SendPickup`) has no client-side preferred-container selection at all today. | item-pickup path (`src/AcDream.App/UI/ItemInteractionController.cs` and siblings) — no consumer wired | A real consumer needs the client-side container-preference decision retail's `PlaceInBackpack` makes, which does not exist in the current pickup flow — future scope. | Toggling the option writes the bit and dirties/auto-saves it correctly, but item pickups route exactly as before (server-decided placement). | `CPlayerSystem::PlaceInBackpack @0x0055d8c0` | | TS-79 | Group D (plan §4 OP4): "Salvage Multiple Materials at Once" (`SalvageMultiple`) and "Disable House Restriction Effects" (`DisableHouseRestrictionEffects`) have no acdream consumer — acdream has no salvage UI (`gmSalvageUI`) and no housing subsystem (`ACCWeenieObject::CanMoveInto`) for either option to gate. | no consumer — both are Character-tab rows, wire+store only | Both require whole unbuilt subsystems (salvage crafting UI; player housing); inventing a stand-in is out of scope for a settings-panel slice. | Toggling either option writes the bit and dirties/auto-saves it correctly, but no observable client behavior changes (both are also currently unreachable — no salvage UI, no housing). | `gmSalvageUI::IsItemSuitable @0x004cb040`; `ACCWeenieObject::CanMoveInto @0x0058da40` | | TS-80 | "Share Fellowship Experience and Luminance" (`PlayerOption FellowshipShareXP`) is Group D's one CLIENT-SOURCED option (character-options-map.md §3): retail's `gmFellowshipUI::CreateFellowship` reads the option value and puts it directly in the fellowship-CREATE wire action; ACE takes XP-sharing from that packet field, never from the stored `CharacterOptions1` bit (`Entity/Fellowship.cs:31,53-54`). Storing the bit alone (this slice's row) is necessary but not sufficient — acdream's own fellowship-create action does not yet read it into the create packet. **PARTIALLY NARROWED 2026-08-12 at Campaign FA slice FA2: the wire mechanism now exists end-to-end — `IRuntimeFellowshipCommands.Create(gen, name, shareXp)` takes and sends `shareXp` on `0x00A2` — but no caller reads `FellowshipShareXP` into that parameter yet (the create dialog is FA4 scope); the risk below is unchanged until that UI lands.** | fellowship-create action (`src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs` `Create`; `src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs` `Create`) — takes `shareXp` as an explicit caller-supplied argument, not yet fed from the option bit | Filed rather than silently assumed correct — a bit that LOOKS wired (toggles, persists, sends `0x0005`) but is never actually consulted by fellowship creation would silently share/withhold XP incorrectly the moment a fellowship is created. | Toggling the option and then creating a fellowship may not honor the toggle — the created fellowship's actual XP-share setting depends on whatever caller value FA4's create dialog passes, unaudited by this slice. | `gmFellowshipUI::CreateFellowship` (address not captured this slice); ACE `Entity/Fellowship.cs:31,53-54` | +| TS-82 | **Filed 2026-08-15 at Campaign CC slice CC4.** The Appearance (`0x100003d4`, `gmCGAppearancePage`) and Summary (`0x100003d6`, `gmCGSummaryPage`) page roots mount as EMPTY, content-inert placeholders — visible/reachable through the master shell's free tab navigation (a player can click their tabs and land on a blank page) but with none of retail's own controls built: no gender/spin/color-wheel/preview on Appearance, no name field/summary listbox/static preview on Summary. Explicitly scoped out per the campaign plan (CC6a/CC6b own Appearance + the 3D preview; CC5 owns Summary + the Finish gate's real UI). The master shell already ports retail's OWN visibility/state-toggle/tab-selection mechanics for both pages faithfully — only their CONTENT is stopgapped. | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`_appearancePageRoot`/`_summaryPageRoot`, mounted but no page controller attached) | Explicitly sequenced follow-on slices (CC5, CC6a, CC6b) own this content; building it here would duplicate work already scoped to those slices and risk drifting from their own DAT/decomp research (Appearance's gender/appearance controls, Summary's name-input filter and Finish gate). | A player reaching Appearance or Summary via free tab navigation sees an empty page instead of retail's controls; Finish stays ghosted (see AP-211's sibling gate) so no create can complete through this screen until CC5 lands. | `gmCGAppearancePage @ 0x0047de70`; `gmCGSummaryPage` (InitializePage @ 136566 per the campaign plan); `docs/plans/2026-08-15-character-creation-campaign.md` (Slices CC5/CC6a/CC6b) | | TS-81 | `0x027A AllegianceLoginNotification`'s retail-faithful two-line chat text (lane C §1.6/§7.1: "is the guid in my cached profile" gate, then a logged-on/logged-off line) is NOT emitted. `RuntimeAllegianceState.ApplyLoginNotification` bumps the snapshot revision only. Retail's own handler chain (`ClientAllegianceSystem::Handle_Allegiance__AllegianceLoginNotificationEvent @0x00569ff0` → `CM_Allegiance::SendNotice_AllegianceLogin @0x006a7330` → `gmAllegianceUI::RecvNotice_AllegianceLogin @0x00492220`) resolves its logged-on/logged-off string via two symbols the Binary Ninja decompiler mis-labels as `gmAllegianceUI::\`vftable'.RecvNotice_PrevSpellTab`/`RecvNotice_UpdateSpellComponents` — a decompiler artifact (the address holds a DAT string-table reference, not those vtable slots; same class CLAUDE.md's BN-literal-0 caution warns about) that must be resolved via `compute_str_hash`/DAT string-table lookup, not guessed. Filed rather than inventing English for the two lines. | `src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs` (`ApplyLoginNotification`) | CLAUDE.md's "no invented user-visible English ever" rule — the candidate strings are BN-mislabeled and unverified from primary source; guessing here is exactly the negligence the workflow rules forbid. | A player never sees retail's "X has logged on/off" allegiance notice; the event still fires and updates Runtime state (usable for a future bot/UI poll), just with no chat line. | `ClientAllegianceSystem::Handle_Allegiance__AllegianceLoginNotificationEvent @0x00569ff0`; `CM_Allegiance::SendNotice_AllegianceLogin @0x006a7330`; `gmAllegianceUI::RecvNotice_AllegianceLogin @0x00492220` | | ~~TS-1~~ | **RETIRED 2026-07-30 (Campaign P Slice P2) — the row was stale, not the code.** The cited `:1254` line is unrelated stepping-loop code; the file moved substantially since the row was written. Retail's `EdgeSlide → PrecipiceSlide / CliffSlide` chain is already a real, tested port: `SpherePath.PrecipiceSlide` (`TransitionTypes.cs:943-970`, retail `SPHEREPATH::precipice_slide` pc:274316), `Transition.CliffSlide` (`:2080-2164`, retail `CTransition::cliff_slide` pc:272397, return-value mapping verified against `acclient.h:6100-6108`), and `Transition.EdgeSlideAfterStepDownFailed` (`:1907-2078`, mirrors `CTransition::edge_slide` pc:273001-273090). The one real gap (back-probe fallback skipping retail's `walkable_check_pos`/`localspace_sphere` recache, pc:274318-274326) needed no code change: acdream's `WalkableVertices`/`GlobalSphere` are populated in unified world space at assignment time (`SetWalkable`/`SetWalkableTransformed`, `SetCheckPos`/`RestoreCheckPos`), so both operands `BSPQuery.FindCrossedEdge` compares are already commensurable — retail's per-cell local-frame reprojection is a no-op correction here. Documented in-code at the back-probe site and pinned by `EdgeSlideBackProbePrecipiceSlideTests`. The chain's two acdream-only compensating branches (CliffSlide's three-source reference-normal fallback; the walkable-steepness reroute to CliffSlide before PrecipiceSlide) are real, non-retail additions — filed as AD-53 / AD-54 rather than folded into this row. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SpherePath.PrecipiceSlide`, `Transition.CliffSlide`, `Transition.EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/EdgeSlideBackProbePrecipiceSlideTests.cs` | — | — | `SPHEREPATH::precipice_slide` pc:274316 (0050cc80); `CTransition::cliff_slide` pc:272397 (0050a6d0); `CTransition::edge_slide` pc:273001-273090 (0050b3d0); `SPHEREPATH::get_walkable_pos`/`cache_localspace_sphere`/`set_walkable_check_pos` pc:274318-274326 (0050a8f0/0050c9d0/00509ce0); `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §2, §6 Step 1 | | ~~TS-4~~ | **RETIRED 2026-07-31 (Campaign P Slice 2B; corrective acceptance complete).** The graph and prepared-flat Path-6 implementations now match retail's exact two-sphere split: every primary/foot polygon hit calls `SetCollide`, sets `WalkableAllowance=LandingZ`, and returns `Adjusted`; only a secondary/head hit writes `CollisionNormal` and returns `Collided`. The steep tangent shortcut and every BSP-layer `SetSlidingNormal` write are deleted. Exact site tests pin all changed and preserved fields plus raw-bit graph/flat parity. A corrective 90-tick already-airborne, zero-root-motion Core suite executes acceleration, body integration, transition resolution, exact commit, and `handle_all_collisions` while retaining every behavior-bearing collision/body field used by that specialized quantum. Vertical, inward, tangential, downhill, and positive-Z uphill-jump traces match graph/flat by raw bits, reject penetration/fixed points/second launches, and pin exact terminal velocity, contact, sliding, and contact-plane state. The older resolver-only capture is explicitly historical and restored to its three-second bound. | `src/AcDream.Core/Physics/BSPQuery.cs`; `src/AcDream.Core/Physics/FlatBspQuery.cs`; `tests/AcDream.Core.Tests/Physics/Ts4Path6ConformanceTests.cs`; `tests/AcDream.Core.Tests/Physics/Ts4ProductionQuantumConformanceTests.cs`; `tests/AcDream.Core.Tests/Physics/Ts4SteepRoofWedgeCaptureTests.cs` | — | — | `BSPTREE::find_collisions` 0x0053A440: head `0x0053A793..0x0053A7A4`, foot `0x0053A7B3..0x0053A7DC`; research §10 | diff --git a/docs/plans/2026-08-15-character-creation-campaign.md b/docs/plans/2026-08-15-character-creation-campaign.md index 2019c2ff..e2264c9f 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -251,7 +251,7 @@ the user gate. | CC1 | REVIEW-CLOSED 2026-08-15 | `04450041`, `cb4703e8` | CLOSED (fix round + narrow re-review; every citation independently re-derived) | Core model (no Chorizite leak) + Content projector; 31 math units + 6 installed-DAT gates (13 heritages). FINDING for CC3: each human heritage's "Adventurer" template IS retail's Custom entry point — attributes at the 10-floor (60/330), a real TemplateCG row, not a UI special case. **Review fix round (`cb4703e8`):** F1 doc corrected — Custom IS template index 0 (the Adventurer row), per `gmCGProfessionPage::UpdateProfession @ 0x004821b0` (case 0 → button 0x100003d9 / `ID_CharGen_CustomText`) and `CharGenState::SetTemplate @ 0x005C5A60` (commits via `CharGenState::ApplyTemplate @ 0x005C5080`, i.e. selecting Custom resets sliders to the floor spread, it does not bypass templates); F2 two-tier skill-cost fallback implemented (`ChargenOptions.GlobalSkillCostsBySkillId` from portal.dat 0x0E000004, `ChargenSkillCreditMath` checks heritage list then global list) + installed-DAT completeness assertion recording reality: the global SkillTable prices 38/54 advancement skill ids, every one of the 13 heritages ships EXACTLY one heritage-specific override (always also present in the global table), and 16 skill ids are genuinely uncostable in both tiers (retail's -1 case) — see `ChargenTableReaderInstalledDatTests.InstalledHeritages_SkillCostFallbackCoversTheKnownUncostableSkillSet`; F3 every `ChargenTableReader` collection is now frozen at projection (`ToFrozenDictionary`/`ToArray`, matching `MagicCatalog`'s pattern) including both `ChargenOptions.Empty` dictionaries; F4 a reflection guard test (`ChargenNoChoriziteLeakTests`) pins the no-Chorizite-leak contract by walking every public `AcDream.Core.CharGen` member; F5 `HasAnyAppearanceOptions`'s doc reworded to state precisely what it proves (an OR across eight lists, omitting the three color lists) + a new installed-DAT gate records per-list reality — found COMPLETE, every gender of every heritage has non-empty lists across all eight plus the three color lists, even the sparse Gear Knight/Olthoi variants; F6 `TryGetHeritage`/`TryGetStarterArea` annotated `[MaybeNullWhen(false)]` (matching the house `EmptyDatReaderWriter` pattern), all affected call sites (more than the originally estimated five) fixed across both test projects. Filed CC7 risk item 8: ACE's `PlayerFactory` heritage-override branch over-deducts skill credits when specializing a heritage-priced skill (references/ACE/Source/ACE.Server/Factories/PlayerFactory.cs:184-211) — a retail-legal build may be rejected by local ACE at the CC7 connected gate; this is an ACE bug, not an acdream defect. **Narrow re-review CLOSED:** the reviewer retro-graded F2 to HIGH (under the base commit 37 of 38 costable skills were charged zero) and confirmed the SkillBase.SpecializedCost->PrimaryCost mapping dodged the UpgradeCostFromTrainedToSpecialized trap. Residuals: R1 retail refunds +1 credit on a both-tier miss (port charges 0; unreachable via retail’s own skills listbox — NOTE FOR CC3 if any path ever exposes the 16 uncostable ids); R2 list downcast-mutability and R3 field-walking in the leak guard CLOSED at the merge-closeout commit (Array.AsReadOnly at every projection seam; GetFields walk added). Decomp fact for CC4: ApplyTemplate force-sets template_=0 for heritage 0xc/0xd — both Olthoi variants are hard-locked to Custom/template 0. | | CC2 | REVIEW-CLOSED, MERGED 2026-08-15 (`55fc51ed`) | `5eaad2c8`, `e77ebf10`, `95e95bb6` | PASS then CLOSED (fix round: F1 latch-scope narrowing + overwrite pin test, F2 register AD-100, F3 ACE double-NameInUse note, F4 creationFailed{code,reason,name}, F5 pointer, retail-discriminator citations) | Byte-exact 0xF656 (19-term checksum vs CG_Pack accumulator), shared 0xF643 type, correlation latch, status events + contract amendment. Core.Net 993 / Runtime 1667 / Launcher.Core 323, Windows+WSL | | CC3 | REVIEW-CLOSED 2026-08-15 | `9a84230c`, `397ccd62`, + the R1 closeout commit | CLOSED (dual-lens: retail fidelity PASS, architectural FAIL → F1-F16 fix round `397ccd62` → narrow re-review CLOSED, both lenses PASS. Re-review residual R1 — the cached wire count is stale by creates-since-last-CharacterList, so a SECOND create after a rejected enter got wire slot N instead of N+1 — fixed in the closeout commit: `LiveSessionController._createsSinceCharacterList` (reset on every fresh wire CharacterList apply + generation reset; applied only to the cached-wire branch — the display-roster fallback already counts prior appends), regression test `SecondCreate_AfterRejectedEnter_GetsTheNextWireSlot` drives create→Ok→rejected guid-enter→ReturnToSelection→second create and pins slots 0/1/2/3. R2: fix-round sha recorded here.) | `RuntimeCharacterCreationState` (new, `src/AcDream.Runtime/Session/`): full CharGenState mirror (heritage/gender/appearance/template/six attributes+locks/55-slot skill set/name/startArea/slot/verification state), mirroring `RuntimeCharacterSelectionState`'s exact pattern (snapshot/delta/event-stream/borrow-only view, generation-gated `Try*` internals). Ports `SetHeritageGroup`, `SetGender`, `SetTemplate`/`ApplyTemplate` (Custom = template 0, Olthoi force-lock), the six attribute setters + `GetAbsRemainingCredits` + `BalanceAttributes` (retail's literal str/end/coord/quick/focus/self round-robin order, cursor-based fairness), `SetSkillLevel` + `ResetSkillLevels`' three-way free-skill baseline (both two-tier cost lookups reuse CC1's `ChargenSkillCreditMath`/`ChargenSkillCost` verbatim — no duplicated math), `RandomizeStartArea`, and `DoFinish`'s complete gate sequence (empty name / unspent attribute credits [see F3 below] / already-Pending / client-side roster-vs-slotCount cap). `LiveSessionController` gained a sibling `IRuntimeCharacterCreationCommands` implementation (command family lands beside `IRuntimeCharacterSelectionCommands`, `IGameRuntimeCommands.CharacterCreation` added with the same default-throw shape as `CharacterSelection`), a `CharacterCreationState` property, `ILiveSessionOperations.CreateCharacter` (default method → `WorldSession.SendCharacterCreation`), and a `HandleCharacterCreationResponse` wire handler subscribed to `WorldSession.CharacterCreateResponseReceived` alongside the existing character-selection bindings. `ILiveSessionLifecycleHost` gained `ApplyCharacterCreated`/`ApplyCreationFailed` as DEFAULT interface methods (no-op) so `AcDream.App`'s existing host implementations keep compiling unchanged — wiring them to `SessionStatusWriter.CharacterCreated`/`CreationFailed` is left to CC4 (Runtime calls the hooks; the App-side forward is a future host-construction change; **F14: zero production call sites exist for these hooks until then — a headless bot cannot observe a create yet**). **Review fix round (this commit):** F1 (HIGH, blocking) the post-create log-straight-in no longer enters by roster INDEX — `WorldSession` gained a guid-based `EnterWorld(uint characterGuid, string accountName, TimeSpan?)` overload (refactored to share `EnterWorldCore` with the index-based overload) plus `ILiveSessionOperations.EnterWorldByGuid` (default method); `LiveSessionController` factored `EnterSelectedCore`/the new `EnterCreatedCharacterCore` through a shared `EnterHighlightedCore(sendEnterWorld)` — the cached wire `CharacterList` is stale for a just-created character by ACE design (ACE appends server-side and replies Ok with no CharacterList resend — `references/ACE/.../CharacterHandler.cs:170-172`), so an index-derived enter could throw (0 pre-existing characters) or enter the WRONG character (N pre-existing, display order ≠ wire order). F2 (HIGH, blocking) the post-create roster append no longer round-trips through `ApplyRoster` (which re-derives EVERY entry's `ActiveIndex` — a wire contract ACE indexes for delete, `CharacterHandler.cs:297` — from display/name-sort order); `RuntimeCharacterSelectionState` gained a real `AppendCreatedCharacter(characterId, name, wireIndex)` primitive that preserves every existing entry's `ActiveIndex` untouched and assigns the new entry's from the pre-create wire `CharacterList.Characters.Count` (0-based, read from the same cached source the index-enter path uses). F3 (MEDIUM-HIGH, blocking) the credit gate was NOT retail — `DoFinish(this, arg2)`'s real gate is `arg2 != 0 && remainingAtrbCredits > 0`: the ordinary click (`arg2=1`) warns-and-refuses, but the warning dialog's own confirm re-invokes `DoFinish(this, 0)`, which skips the check and sends with credits unspent (ACE accepts this). `TryBeginFinish`/`LiveSessionController.Finish`/`IRuntimeCharacterCreationCommands.Finish` gained a `confirmedUnspentCredits`/`confirmUnspentCredits` parameter (default `false` = retail's `arg2=1`) — the plan doc's own "retail FORCES full spend" line above (§Retail ground truth, Finish) was corrected in the same round. F4 (MEDIUM, blocking) a stale out-of-range template index surviving a heritage switch to a heritage with fewer templates now clears to `TemplateUnset` in `ApplyTemplateLocked`, mirroring `ConstrainAllByHeritage @ 0x005C65CC`'s `template_ >= count → template_ = 0xffffffff` clamp (previously it just returned, leaving the stale index to reach the wire). F5 (MEDIUM) AP-207's anchor was wrong (`SetAttribValue` never calls `FitTemplateToCharacter`) — corrected to the four real call sites, including a fourth the original filing also missed (`UpdateToDefaultAttributes @ 0x00482860`). F6 (MEDIUM) `ApplyCreationResponse`'s Pending/Undef branch no longer publishes from inside `lock(_gate)` — every branch now sets `kind` and a single `Publish` runs after the lock releases, matching every sibling method. F7 (MEDIUM) two new tests pin `BalanceAttributes`' persistent cursor: successive overspends absorb from different attributes, and the Self→Strength wrap. F8 (LOW) `ResetSkillLevels`' doc corrected — retail's real gate is BOTH costs `>= 0` (not "either tier"); the dictionary-presence equivalence is a CC1-established, installed-DAT-gated invariant, cited precisely. F9 (LOW) the `Slot` doc corrected — retail DOES assign it (`gmCharacterManagementUI::SelectCharacter @ 0x004EC160` → `SetSlot(GetSlot(...))`), just semantically stale (the last-selected PRE-EXISTING character's slot); conclusion (send 0) unchanged. F10 (LOW) AP-209's `classID` citation completed with the three heritage-dependent branch ids (ordinary/Olthoi/OlthoiAcid) plus admin variants. F11 the integration test fixture no longer stubs `EnterWorld` to a bare counter — it captures guid-based calls and the fixture now has two pre-existing characters whose wire order deliberately differs from alphabetical order, so the roster-preservation assertion actually exercises F2 instead of coinciding with it by accident. F12 filed register row AP-211 for the client-side `RosterFull` slot-cap refusal (acdream-side gate, no retail `DoFinish`-layer counterpart — same-commit rule). F13 `LiveSessionController.Finish`'s bare `catch {}` narrowed to `InvalidOperationException`/`SocketException` and `_scope` bound to a local after validation. F15 `RandomizeStartAreaLocked` now leaves `_startArea` unchanged on an empty list (matching retail's `if (var_9c > 0)` guard) instead of forcing `-1`. Filed register rows AP-207 (FitTemplateToCharacter's FPU-unrecoverable auto-detect skipped — ACE only reads `TemplateOption` for title text; anchor corrected this round), AP-208 (per-style color-count approximated by the shared gender-wide `ClothingColors` list — CC1's model has no per-style palette data), AP-209 (`classID` sent as a placeholder `0` — DAT DID lookup unavailable in Core, ACE ignores the field; branch table added this round), AP-210 (`ApplyTemplate`'s per-attribute guarded sequential set approximated as one atomic replace), AP-211 (this round — the `RosterFull` client-side slot-cap refusal). Tests: `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (34 cases — every Finish gate including the F3 confirmed-credits path, the F4 stale-template clamp, the F7 cursor-advance/wrap pair, Ok/each-rejection-code response mapping, duplicate-NameInUse tolerance, Olthoi template lock, attribute-lock/balance interaction, uncostable-skill rejection, generation reset) + `.../Session/LiveSessionControllerCharacterCreationTests.cs` (5 cases — wire-send exactly 55 skill slots via a REAL `WorldSession` + `GameMessageCapture`, decoded byte-for-byte; the full Ok round trip via `WorldSession.ProcessDatagram` reflection asserting F1's guid-based enter + F2's ActiveIndex-preserving roster append + `ApplyCharacterCreated`; the NameInUse round trip asserting `ApplyCreationFailed` + no roster/enter side effect; the local-refusal-never-touches-the-wire gate; the F3 confirmed-unspent-credits send). Runtime 1706/0 (was 1701, was 1667), Core.Net unchanged at 994/0, full solution Release build green. OPEN for CC4+: `RuntimeCharacterCreationState`'s `ChargenOptions` currently defaults to `ChargenOptions.Empty` — threading the installed DAT's loaded options through `GameRuntime`/App startup is unresolved; the `Slot` field's real assignment source (which caller picks the target roster slot) has no decomp citation (ACE ignores it, non-load-bearing); `classID`'s real DAT-DID resolution (AP-209) if a non-ACE server ever needs it; the F14 zero-call-site status hooks. | -| CC4 | — | | | | +| CC4 | CODE-COMPLETE 2026-08-15 | this commit | Not yet reviewed (Opus dual-lens owed) | Screen shell + form pages (App layer). **Mount:** `CharacterCreationUiController`/`CharacterCreationUiMountCoordinator` (`src/AcDream.App/UI/Layout/`) clone `CharacterManagementUiController`'s recipe — enum `0x10000039` via `RetailDataIdResolver.Resolve(dats, ..., 5u)`, root `0x100003CC` (decomp-verified: `gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0`, NOT the plan doc's earlier `0x100003cc`-adjacent guesses — confirmed live against the installed DAT, `[CC4-DAT] enum=0x10000039 -> DID=0x21000038`), fixed-canvas AD-98 treatment shared idempotently with char-management (never nulled on close, so char-management's own per-tick set survives). **Master shell:** progress bar `0x100003ce`, master page `0x100003d0` (state `0x10000025+page-1`), 6 page roots, 6 free-navigation tabs (`0x100003ef..f4`), nav buttons `0x100003c6..cb` — full decomp port of `gmCharGenMainUI::ListenToElementMessage @ 0x004e9450` (Back-at-Heritage→DoExit, Next capped at Summary, Finish Summary-only) and `SetProgressState @ 0x004e7a10` (the Olthoi Profession/Skills/Town tab-hide + forward/backward page redirect, keyed off the LIVE snapshot heritage id every call). Exit confirmation via `RetailDialogFactory.MakeConfirmation` + `ID_CharGen_ExitWarning` (table `0x23000002`, matching `DoExit @ 0x004e8650`); on confirm the screen just closes (visibility only — see AD-99's sibling precedent) rather than porting `gmEpilogueUI`. **Heritage page** (`CharacterCreationHeritagePage.cs`, decomp `InitializePage @ 0x00483a10` + the EXACT button-id→heritage-id map read off `ListenToElementMessage @ 0x00483860`, which is NOT numeric-order — e.g. `0x100005e8`→Tumerok(7)): all 13 buttons, composed description text (`ID_CharGen_Heritage_StartingSkills_Header/Body`, `ID_CharGen_Heritage_BonusSkills_Trained_Header` + per-heritage body — Shadowbound/Penumbraen share one string per the decomp's `case 5: case 0xa:`; Lugian/Olthoi/OlthoiAcid have no bonus-skills string in the retail table at all, confirmed by string-key absence, not guessed). Selecting a heritage ALSO auto-selects its lowest gender key (AD-101 — Appearance's real gender buttons are CC6b's). **Profession page** (`CharacterCreationProfessionPage.cs`, `InitializePage @ 0x00482d50` + `UpdateProfession @ 0x004821b0`'s template map, cited already on `ChargenTemplate`): 7 template buttons (Custom=index 0, the six presets NOT in id order), 6 attribute sliders with the exact e6/e7/e9/e8/ea/eb id↔attribute-id mapping (the documented 3/4 swap), avail/health/stamina/mana. Live-DAT probe found TWO widget-mapping surprises the decomp's `DynamicCast` calls don't predict: the slider's value display (`0x100002ef`) imports as `UiField` not `UiText` (retail's `NumberInputFilter`, `@0x00482e36`) — wired for direct numeric entry via `OnSubmit`, not just display; and all four avail/health/stamina/mana containers (and the Skills credits meter) author as `UIElement_Button` whose Type-12 value child is swallowed by `UiButton.ConsumesDatChildren` before ever becoming an addressable widget — substituted with the button's own `.Label` (AD-103). Health/Stamina/Mana formulas ported from `UpdateAttributeValues @ 0x00482450`: Health=Endurance/2 (int truncation — the decompiler elides the FPU divide at `_ftol2 @0x0048262b`, so the exact MSVC rounding mode is UNVERIFIED beyond well-established AC convention; flagged, not guessed-and-hidden), Stamina=Endurance, Mana=Self; Available=`RemainingAttributeCredits` directly (`UpdateCreditsMeter`-style, no formula). **Skills page** (`CharacterCreationSkillsPage.cs`, `InitializePage @ 0x00481dd0`): ONE flat listbox (AP-213, retail's four-bucket sorted `InsertEntrySorted`/`UpdateSkillEntry` model not ported) driven by CC3's `TrainSkill`/`SpecializeSkill`/`UntrainSkill` + the SAME two-tier `TryGetSkillCost` presence gate `RuntimeCharacterCreationState` uses (16 uncostable ids never listed, matching retail); credits meter via the AD-103 button-Label substitution; info panes `0x100003fb/fc` unbound (no info-pane content source this round). **Town page** (`CharacterCreationTownPage.cs`, `InitializePage @ 0x0047c6d0` + `SetTown @ 0x0047c360`'s literal index map): the four buttons map to LITERAL `startArea` indices (Sanamar→3, Holtburg→0, Yaraq→2, Shoushi→1 — not id order), composed "How To" + per-town description text. **Random** (`0x100003cb`, `DoRandom @ 0x004e7d70`): Heritage/Profession/Town approximated with a uniform pick over every valid option (AP-212 — no `RandomizeHeritageGroup`/`RandomizeTemplate` primitives exist); disabled outright on Skills (no `RandomizeSkills` primitive), Appearance (placeholder), Summary (CC5's warning dialog). **Options threading:** `RuntimeCharacterCreationState.InstallOptions(ChargenOptions)` (new, mirrors `RuntimeCharacterState.InstallSpellMetadata`→`Spellbook.InstallMetadata`'s "install immutable DAT metadata after construction, throw if already active" pattern) called from `ContentEffectsAudioCompositionPhase.Compose` (new `ChargenOptionsInstalled` composition point, right after `SpellMetadataInstalled`) via `IContentEffectsAudioCompositionFactory.LoadChargenOptions`/`InstallChargenOptions` — `ChargenTableReader.Load(dats)` threaded through the SAME DAT-open composition sequence spell metadata uses, always well before any session's `Begin()`. Headless is unaffected (`DirectGameRuntimeCommandAdapter`/`HeadlessSessionHost` never call `InstallOptions`, so headless bots keep the CC3-documented `ChargenOptions.Empty` default — matches the brief). **Status hooks:** `LiveSessionLifecycleBindings` gained optional `CharacterCreated`/`CreationFailed` delegates (default `null` — every pre-CC4 construction site keeps compiling); `LiveSessionLifecycleHost` now overrides both `ILiveSessionLifecycleHost` methods to forward them; `LiveSessionHostBindings` gained matching optional fields threaded through `LiveSessionHost`'s constructor; both `LiveSessionRuntimeFactory.Create` (App/graphical) and `HeadlessSessionHost` wire them to `SessionStatusWriter.CharacterCreated`/`CreationFailed`, closing CC3's F14 (zero call sites). **Deferred command seam:** `IGameRuntimeView.CharacterCreation` (new default-throw member, mirrors `CharacterSelection`), `GameRuntime.CharacterCreation` (passthrough to `Session.CharacterCreation`), `CurrentGameRuntimeAdapter`'s new `CharacterCreationProjection` (IsActive-gated view+command wrapper, mirrors `CharacterSelectionProjection`), `DeferredGameRuntimeStateCommands`'s new `CharacterCreation` view getter + 9 generation-capturing wrapper methods, and `CharacterCreationRuntimeBindings` wired in `InteractionRetainedUiComposition.cs` (`CharacterCreation:` sibling of `CharacterSelection:`, `ResolveText` backed by a fresh `DatStringResolver` per call under `d.DatLock`, `OpenOnStart` from the new `RuntimeOptions.OpenCharacterCreationOnStart` / `ACDREAM_OPEN_CHARGEN=1` env flag — the interim open seam since Create stays ghosted). **Widget types added to `DatWidgetFactory`: NONE** — every id resolves through EXISTING factory mappings (Button=1, Text/Field=12, Scrollbar=11, ListBox=5); the two "new" findings (editable-Field slider value, button-consumed credits/vitals children) are AUTHORED-DATA-DRIVEN outcomes of the existing factory logic, not new widget classes. **Register rows filed (same commit):** AD-101 (Heritage-page auto-gender-select interim default), AD-102 (Viamontian/Sanamar ToD-account-ownership gate omitted — acdream has no account/DLC signal), AD-103 (avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays), AP-212 (Random button's uniform-pick approximation), AP-213 (Skills page flat-listbox simplification), TS-82 (Appearance/Summary placeholder pages, reachable via free tab nav, content-inert pending CC5/CC6a/CC6b). **Tests:** `tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs` (7 cases, `ACDREAM_PROBE_LIVE_MOUNT=1`-gated — sweeps every master-shell/page id against the installed DAT and pins the two widget-mapping surprises above) + `CharacterCreationUiControllerTests.cs` (16 cases — hand-built layout fixture, no DAT: page switching, Olthoi tab-hide+redirect, Back/Exit/Random gating, exit-confirm/cancel, per-page command dispatch including the slider/field/skill-row/town-button paths) + `tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs` (+4 `InstallOptions` cases) + `tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs` (+2 status-hook forwarding cases). Runtime 1713/0 (was 1707), App 5117/13 skips (was 5101/6, +16 new +7 gated-skip), Headless 165/0 unaffected, full solution Release build green. **OPEN for CC5/CC6a/CC6b:** the real Appearance-page gender buttons must retire AD-101's auto-select; Summary's Finish gate, name input, and randomize-warning dialog (currently Finish/Random both hard-disabled); Skills page info-panes `0x100003fb/fc` have no content source wired yet; the four-bucket sorted skill list (AP-213) and retail's exact Random algorithms (AP-212) remain unported if a future gate demands byte-exact parity; the Health/Stamina/Mana rounding-mode residual (see above) would need a live cdb byte trace to fully pin. | | CC5 | — | | | | | CC6a | — | | | | | CC6b | — | | | | diff --git a/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs b/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs index c643ed07..5b6e72a3 100644 --- a/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs +++ b/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs @@ -12,9 +12,11 @@ using AcDream.Core.Physics; using AcDream.Core.Rendering; using AcDream.Core.Spells; using AcDream.Core.Vfx; +using AcDream.Core.CharGen; using AcDream.Runtime; using AcDream.Runtime.Gameplay; using AcDream.Runtime.Physics; +using AcDream.Runtime.Session; using DatReaderWriter; using Silk.NET.Input; @@ -63,6 +65,14 @@ internal sealed record ContentEffectsAudioDependencies( Action Error) { public RuntimeCharacterState Character => Runtime.CharacterOwner; + + /// Campaign CC slice CC4: the character-creation options + /// install target — see ContentEffectsAudioCompositionPhase.Compose's + /// ChargenOptionsInstalled step and + /// 's own doc + /// for why this is safe at composition time (strictly before any + /// session's Begin). + public LiveSessionController Session => Runtime.Session; } internal interface IGameWindowContentEffectsAudioPublication @@ -96,6 +106,13 @@ internal interface IContentEffectsAudioCompositionFactory RuntimeCharacterState character, MagicCatalog catalog); int GetSpellCount(MagicCatalog catalog); + /// Campaign CC slice CC4: mirrors the + /// / + /// pair's "load off dats, install once onto the owning Runtime state" + /// shape for the chargen options + /// (AcDream.Content.CharGen.ChargenTableReader.Load). + ChargenOptions LoadChargenOptions(IDatReaderWriter dats); + void InstallChargenOptions(LiveSessionController session, ChargenOptions options); IAnimationLoader CreateAnimationLoader( IDatReaderWriter dats, long maximumEstimatedBytes, @@ -166,6 +183,12 @@ internal sealed class RetailContentEffectsAudioCompositionFactory public int GetSpellCount(MagicCatalog catalog) => catalog.SpellTable.Count; + public ChargenOptions LoadChargenOptions(IDatReaderWriter dats) => + AcDream.Content.CharGen.ChargenTableReader.Load(dats); + + public void InstallChargenOptions(LiveSessionController session, ChargenOptions options) => + session.CharacterCreationState.InstallOptions(options); + public IAnimationLoader CreateAnimationLoader( IDatReaderWriter dats, long maximumEstimatedBytes, @@ -270,6 +293,7 @@ internal enum ContentEffectsAudioCompositionPoint PreparedAssetSourcePublished, MagicCatalogPublished, SpellMetadataInstalled, + ChargenOptionsInstalled, AnimationLoaderPublished, CollisionBuilderPublished, EmitterRegistryPublished, @@ -362,6 +386,12 @@ internal sealed class ContentEffectsAudioCompositionPhase : $"spells: loaded {_factory.GetSpellCount(magic)} entries from portal.dat"); Fault(ContentEffectsAudioCompositionPoint.SpellMetadataInstalled); + ChargenOptions chargen = _factory.LoadChargenOptions(dats); + _factory.InstallChargenOptions(_dependencies.Session, chargen); + _dependencies.Log( + $"chargen: loaded {chargen.HeritagesById.Count} heritage(s) from portal.dat"); + Fault(ContentEffectsAudioCompositionPoint.ChargenOptionsInstalled); + IAnimationLoader animations = _factory.CreateAnimationLoader( dats, _dependencies.ResidencyBudgets.AnimationBytes, diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 6673cdf9..e1b93544 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -962,6 +962,37 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory // /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: () => { }, + ResolveText: key => + { + lock (d.DatLock) + { + return new DatStringResolver(d.Dats).Resolve( + 0x23000002u, + DatStringResolver.ComputeHash(key)); + } + }, + OpenOnStart: d.Options.OpenCharacterCreationOnStart) : null); RetailUiRuntime runtime = lease.Mount( () => RetailUiRuntime.CreateUninitialized(bindings)); diff --git a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs index d7ddb795..8ea0680c 100644 --- a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs +++ b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs @@ -56,6 +56,19 @@ internal sealed class DeferredGameRuntimeStateCommands } } + /// Campaign CC slice CC4: same late-bound borrow shape as + /// . + public IRuntimeCharacterCreationView? CharacterCreation + { + get + { + lock (_gate) + return !_deactivated && _view is not null + ? _view.CharacterCreation + : null; + } + } + public IDisposable Bind( IGameRuntimeView view, IGameRuntimeCommands commands) @@ -166,6 +179,54 @@ internal sealed class DeferredGameRuntimeStateCommands 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 FA slice FA4: fellowship page commands ───────────────── // Same "capture view+commands under one generation" shape as every // method above — a displaced session (reconnect mid-click) can never diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 8a9e3886..6af15653 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -221,7 +221,20 @@ internal sealed class LiveSessionRuntimeFactory _sessionId, selection.CharacterId, selection.CharacterName), - LoginCommands: loginCommands), + LoginCommands: loginCommands, + // Campaign CC slice CC4: the two sibling events to Roster/ + // CharacterEntered above — see SessionStatusWriter's own doc + // for why characterCreated precedes an eventual enteredWorld + // rather than replacing it. + CharacterCreated: identity => _statusWriter.CharacterCreated( + _sessionId, + identity.Guid, + identity.Name), + CreationFailed: rejection => _statusWriter.CreationFailed( + _sessionId, + rejection.RawCode, + rejection.Reason, + rejection.AttemptedName)), connectOptions); } diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs index 930e8412..cf6b8d14 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs @@ -1,5 +1,6 @@ using AcDream.App.Interaction; using AcDream.App.Net; +using AcDream.Core.CharGen; using AcDream.Runtime; using AcDream.Runtime.Session; using AcDream.Runtime.World; @@ -21,6 +22,7 @@ internal sealed class CurrentGameRuntimeAdapter private readonly GameRuntime _runtime; private readonly CurrentGameRuntimeCommandAdapter _commands; private readonly CharacterSelectionProjection _characterSelection; + private readonly CharacterCreationProjection _characterCreation; private readonly IDisposable _hostLease; private readonly object _subscriptionGate = new(); private readonly HashSet _subscriptions = []; @@ -42,6 +44,7 @@ internal sealed class CurrentGameRuntimeAdapter try { _characterSelection = new CharacterSelectionProjection(this); + _characterCreation = new CharacterCreationProjection(this); _commands = new CurrentGameRuntimeCommandAdapter( runtime.Session, sessionHost, @@ -93,6 +96,8 @@ internal sealed class CurrentGameRuntimeAdapter public IRuntimeChatView Chat => _runtime.Chat; public IRuntimeCharacterSelectionView CharacterSelection => _characterSelection; + public IRuntimeCharacterCreationView CharacterCreation => + _characterCreation; public IRuntimeFellowshipView Fellowship => _runtime.Fellowship; public IRuntimeAllegianceView Allegiance => _runtime.Allegiance; public IRuntimeActionView Actions => _runtime.Actions; @@ -105,6 +110,10 @@ internal sealed class CurrentGameRuntimeAdapter _characterSelection; IRuntimeCharacterSelectionCommands IGameRuntimeCommands.CharacterSelection => _characterSelection; + public IRuntimeCharacterCreationCommands CharacterCreationCommands => + _characterCreation; + IRuntimeCharacterCreationCommands IGameRuntimeCommands.CharacterCreation => + _characterCreation; public IRuntimeSelectionCommands Selection => _commands; public IRuntimeCombatCommands Combat => _commands; public IRuntimeMagicCommands Magic => _commands; @@ -281,6 +290,83 @@ internal sealed class CurrentGameRuntimeAdapter } } + // ── Campaign CC slice CC4: character creation, same shape as the + // character-selection block above. ──────────────────────────────── + + private RuntimeCharacterCreationSnapshot CharacterCreationSnapshot() + { + lock (_subscriptionGate) + { + if (IsActive) + return _runtime.CharacterCreation.Snapshot; + return default; + } + } + + private ChargenSkillAdvancementClass CharacterCreationSkillLevel(uint skillId) + { + lock (_subscriptionGate) + { + return IsActive + ? _runtime.CharacterCreation.GetSkillLevel(skillId) + : ChargenSkillAdvancementClass.Inactive; + } + } + + private ChargenOptions CharacterCreationOptions() + { + lock (_subscriptionGate) + { + return IsActive + ? _runtime.CharacterCreation.Options + : ChargenOptions.Empty; + } + } + + private IDisposable SubscribeCharacterCreation( + IRuntimeCharacterCreationObserver observer) + { + ArgumentNullException.ThrowIfNull(observer); + lock (_subscriptionGate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + var gated = new AdapterCharacterCreationObserver(this, observer); + IDisposable runtimeSubscription = + _runtime.CharacterCreation.Subscribe(gated); + var subscription = new AdapterSubscription( + this, + runtimeSubscription); + _subscriptions.Add(subscription); + return subscription; + } + } + + private RuntimeCommandResult ExecuteCharacterCreation( + Func execute) + { + lock (_subscriptionGate) + { + if (!IsActive) + { + return new RuntimeCommandResult( + RuntimeCommandStatus.Inactive, + _runtime.Generation); + } + return execute(_runtime.Session); + } + } + + private void ForwardCharacterCreation( + IRuntimeCharacterCreationObserver observer, + in RuntimeCharacterCreationDelta delta) + { + lock (_subscriptionGate) + { + if (IsActive) + observer.OnCharacterCreationChanged(in delta); + } + } + private sealed class CharacterSelectionProjection( CurrentGameRuntimeAdapter owner) : IRuntimeCharacterSelectionView, @@ -350,6 +436,126 @@ internal sealed class CurrentGameRuntimeAdapter 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)); + } + + private sealed class AdapterCharacterCreationObserver( + CurrentGameRuntimeAdapter owner, + IRuntimeCharacterCreationObserver observer) + : IRuntimeCharacterCreationObserver + { + public void OnCharacterCreationChanged( + in RuntimeCharacterCreationDelta delta) => + owner.ForwardCharacterCreation(observer, in delta); + } + private sealed class AdapterSubscription( CurrentGameRuntimeAdapter owner, IDisposable runtimeSubscription) : IDisposable diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 155cd8ba..1d69d3e9 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -53,6 +53,12 @@ public sealed record RuntimeOptions( bool DumpClothing, int? LegacyStreamRadius, bool RetailUi, + /// Campaign CC slice CC4: interim env/test-only seam that opens + /// the character-creation screen once Runtime's chargen view goes + /// active — the real transition is retail's Create Character button + /// (0x100003A0), which stays ghosted until CC7's closing move. + /// See CharacterCreationRuntimeBindings.OpenOnStart. + bool OpenCharacterCreationOnStart, string? AcDir, bool UiProbeDump, string? UiProbeScript, @@ -146,6 +152,8 @@ public sealed record RuntimeOptions( // top of the quality preset's radii. Null when unset or invalid. LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")), RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")), + OpenCharacterCreationOnStart: + IsExactlyOne(env("ACDREAM_OPEN_CHARGEN")), AcDir: NullIfEmpty(env("ACDREAM_AC_DIR")), UiProbeDump: IsExactlyOne(env("ACDREAM_UI_PROBE_DUMP")), UiProbeScript: NullIfEmpty(env("ACDREAM_UI_PROBE_SCRIPT")), diff --git a/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs b/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs new file mode 100644 index 00000000..e9274c71 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs @@ -0,0 +1,201 @@ +using AcDream.Core.CharGen; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.App.UI.Layout; + +/// +/// The Heritage page (gmCGHeritagePage, root 0x100003d1) — 13 +/// race buttons and the composed description text. Decomp anchors: +/// gmCGHeritagePage::InitializePage @ 0x00483a10 (button ids), +/// gmCGHeritagePage::ListenToElementMessage @ 0x00483860 (the exact +/// button-id -> heritage-id map), gmCGHeritagePage::Update @ +/// 0x00483210 (description text composition). +/// +internal sealed class CharacterCreationHeritagePage : IDisposable +{ + /// + /// Button element id -> CharGenState::SetHeritageGroup argument, + /// read verbatim off gmCGHeritagePage::ListenToElementMessage @ + /// 0x00483860's per-case literal (NOT the button element ids' + /// numeric order — e.g. 0x100005e8 maps to heritage 7/Tumerok, not to + /// its own position among the 13 ids). + /// + private static readonly IReadOnlyDictionary HeritageByButtonId = + new Dictionary + { + [0x100003BFu] = (uint)ChargenHeritageGroup.Aluvian, + [0x100003C1u] = (uint)ChargenHeritageGroup.Gharundim, + [0x100003C2u] = (uint)ChargenHeritageGroup.Sho, + // Retail gates this button (Viamontian) behind + // AccountHasThroneOfDestiny (MakeToDWarningDialog otherwise, + // @0x004838e5) — acdream has no account/DLC-ownership signal + // anywhere in ChargenOptions, so this ships without the gate + // (register AD-102, same row as the Town page's Sanamar gate). + [0x100003C3u] = (uint)ChargenHeritageGroup.Viamontian, + [0x10000590u] = (uint)ChargenHeritageGroup.Shadowbound, + [0x100005A9u] = (uint)ChargenHeritageGroup.Gearknight, + [0x100005E8u] = (uint)ChargenHeritageGroup.Tumerok, + [0x100005F1u] = (uint)ChargenHeritageGroup.Lugian, + [0x100005C4u] = (uint)ChargenHeritageGroup.Empyrean, + [0x10000591u] = (uint)ChargenHeritageGroup.Penumbraen, + [0x100005BFu] = (uint)ChargenHeritageGroup.Undead, + [0x100005C7u] = (uint)ChargenHeritageGroup.Olthoi, + [0x100005C8u] = (uint)ChargenHeritageGroup.OlthoiAcid, + }; + + /// + /// ID_CharGen_<Abbrev>Text_BonusSkills_Trained per + /// gmCGHeritagePage::Update's heritage switch (@0x004833e3): + /// Shadowbound and Penumbraen share the SAME string + /// (case 5: case 0xa:, both resolve "ShadText"). Lugian/Olthoi/ + /// OlthoiAcid have no matching string in the retail string table (the + /// decompiled switch's cases 8/0xc/0xd resolve to a vtable-slot + /// artifact instead of a string literal, and no + /// "ID_CharGen_Lug*"/"ID_CharGen_Olthoi*" key exists anywhere in the + /// named-retail dump) — those three heritages simply show the shared + /// header text with no per-heritage bonus-skills line, which is + /// retail's own real behavior here, not an acdream gap. + /// + private static readonly IReadOnlyDictionary BonusSkillsKeyByHeritage = + new Dictionary + { + [(uint)ChargenHeritageGroup.Aluvian] = "ID_CharGen_AluvianText_BonusSkills_Trained", + [(uint)ChargenHeritageGroup.Gharundim] = "ID_CharGen_GaruText_BonusSkills_Trained", + [(uint)ChargenHeritageGroup.Sho] = "ID_CharGen_ShoText_BonusSkills_Trained", + [(uint)ChargenHeritageGroup.Viamontian] = "ID_CharGen_ViaText_BonusSkills_Trained", + [(uint)ChargenHeritageGroup.Shadowbound] = "ID_CharGen_ShadText_BonusSkills_Trained", + [(uint)ChargenHeritageGroup.Penumbraen] = "ID_CharGen_ShadText_BonusSkills_Trained", + [(uint)ChargenHeritageGroup.Gearknight] = "ID_CharGen_GearText_BonusSkills_Trained", + [(uint)ChargenHeritageGroup.Tumerok] = "ID_CharGen_AunTText_BonusSkills_Trained", + [(uint)ChargenHeritageGroup.Empyrean] = "ID_CharGen_EmpText_BonusSkills_Trained", + [(uint)ChargenHeritageGroup.Undead] = "ID_CharGen_UndText_BonusSkills_Trained", + }; + + private readonly CharacterCreationRuntimeBindings _bindings; + private readonly Dictionary _buttons = []; + private readonly UiText? _description; + private bool _disposed; + + internal CharacterCreationHeritagePage( + UiElement pageRoot, + CharacterCreationRuntimeBindings bindings) + { + _bindings = bindings; + foreach ((uint buttonId, uint heritageId) in HeritageByButtonId) + { + if (UiElement.FindDescendant(pageRoot, buttonId) is not UiButton button) + continue; + _buttons[button] = heritageId; + button.OnClick = () => Select(heritageId); + } + + _description = UiElement.FindDescendant(pageRoot, 0x100003C4u) as UiText; + } + + internal void Refresh( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + foreach ((UiButton button, uint heritageId) in _buttons) + button.Selected = heritageId == snapshot.HeritageId; + + if (_description is null) + return; + + string composed = ComposeDescription(view, snapshot.HeritageId, _bindings.ResolveText); + _description.LinesProvider = () => + [new UiText.Line(composed, _description.DefaultColor)]; + } + + 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); + } + + private void Select(uint heritageId) + { + if (_disposed) + return; + RuntimeCommandResult result = _bindings.SelectHeritage(heritageId); + if (!result.Accepted) + return; + + // CC4 interim default (register AD-101): the Profession/Skills/Town + // pages this slice builds need heritage+gender both selected + // (RuntimeCharacterCreationState.TrySelectTemplate's gate), but + // gender selection lives on the Appearance page (0x100003a7/a8), + // which stays an inert placeholder until CC6b. Auto-select the + // heritage's first available gender so those pages remain usable; + // CC6b's real gender buttons supersede this and the row retires + // then. + IRuntimeCharacterCreationView? view = _bindings.View(); + if (view is not null + && view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage) + && heritage.GendersByKey.Count > 0) + { + int genderKey = heritage.GendersByKey.Keys.Min(); + _bindings.SelectGender((uint)genderKey); + } + } + + /// + /// Ports gmCGHeritagePage::Update @ 0x00483210's text + /// composition: the (heritage-independent) starting-skills header + + /// body, the bonus-skills header, then — only once a heritage is + /// selected — that heritage's own bonus-skills line (absent for + /// Lugian/Olthoi/OlthoiAcid; see ). + /// is the DAT string lookup + /// (RetailUiRuntime's DatStringResolver over table + /// 0x23000002) threaded through the bindings record; a missing + /// resolver or a missing key degrades to skipping that segment rather + /// than throwing. + /// + private static string ComposeDescription( + IRuntimeCharacterCreationView view, + uint heritageId, + Func? resolveText) + { + if (resolveText is null) + { + return view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? named) + ? named.Name + : string.Empty; + } + + var parts = new List(); + if (resolveText("ID_CharGen_Heritage_StartingSkills_Header") is { } header) + parts.Add(header); + if (resolveText("ID_CharGen_Heritage_StartingSkills") is { } body) + parts.Add(body); + if (resolveText("ID_CharGen_Heritage_BonusSkills_Trained_Header") is { } bonusHeader) + parts.Add(bonusHeader); + if (heritageId != 0 + && BonusSkillsKeyByHeritage.TryGetValue(heritageId, out string? bonusKey) + && resolveText(bonusKey) is { } bonusBody) + { + parts.Add(bonusBody); + } + return string.Join("\n\n", parts); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + foreach (UiButton button in _buttons.Keys) + button.OnClick = null; + _buttons.Clear(); + } +} diff --git a/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs new file mode 100644 index 00000000..315003aa --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs @@ -0,0 +1,264 @@ +using System.Globalization; +using AcDream.Core.CharGen; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.App.UI.Layout; + +/// +/// The Profession page (gmCGProfessionPage, root 0x100003d2) — +/// seven template buttons and the six attribute sliders. Decomp anchors: +/// gmCGProfessionPage::InitializePage @ 0x00482d50 (slider/display +/// element ids), gmCGProfessionPage::UpdateProfession @ 0x004821b0 +/// (template-index -> button-id map, cited on ChargenTemplate), +/// gmCGProfessionPage::UpdateAttributeValues @ 0x00482450 +/// (avail/health/stamina/mana display sourcing). +/// +internal sealed class CharacterCreationProfessionPage : IDisposable +{ + /// Template button id -> template index, verbatim off + /// gmCGProfessionPage::UpdateProfession @ 0x004821b0's per-case + /// button-highlight dispatch (also the doc comment on + /// ChargenTemplate): 0 is Custom/Adventurer, and the six preset + /// buttons do NOT sit in template-index order. + private static readonly IReadOnlyDictionary TemplateByButtonId = + new Dictionary + { + [0x100003D9u] = 0u, // Custom / Adventurer + [0x100003DAu] = 1u, // Bow Hunter + [0x100003DFu] = 2u, // Swashbuckler + [0x100003DBu] = 3u, // Life Caster + [0x100003DCu] = 4u, // War Caster (aka War Mage) + [0x100003DDu] = 5u, // Wayfarer + [0x100003DEu] = 6u, // Soldier + }; + + /// + /// Attribute id -> slider container element id, verbatim off + /// gmCGProfessionPage::InitializePage @ 0x00482d50: + /// m_tSliderArray[N].pAttribField = GetChildRecursive(this, + /// id) for N=1..6 against ids 0x100003e6, e7, e9, e8, ea, eb + /// — note the e8/e9 SWAP (id e9 is slider index 3/Quickness, id e8 is + /// slider index 4/Coordination), matching + /// 's own documented 3/4 swap. + /// + private static readonly IReadOnlyDictionary SliderContainerByAttribute = + new Dictionary + { + [ChargenAttributeId.Strength] = 0x100003E6u, + [ChargenAttributeId.Endurance] = 0x100003E7u, + [ChargenAttributeId.Coordination] = 0x100003E8u, + [ChargenAttributeId.Quickness] = 0x100003E9u, + [ChargenAttributeId.Focus] = 0x100003EAu, + [ChargenAttributeId.Self] = 0x100003EBu, + }; + + // Relative (within-container) child ids, same InitializePage loop: + // 0x100002ec = lock UIElement_Button, 0x100002ed = name UIElement_Text + // (left at its authored default — see the ctor comment), + // 0x100002ee = the UIElement_Scrollbar drag control, 0x100002ef = the + // value display. Live-DAT probe (CharacterCreationLiveDatTests): + // 0x100002ef imports as a UiField, not UiText — retail's + // NumberInputFilter (attached to the sibling name field in the decomp, + // @0x00482e36) authors the whole slider row's text sub-elements as + // editable-capable; acdream's factory maps that authored shape to + // UiField. This also lets the player type an exact value directly. + private const uint SliderLockRelativeId = 0x100002ECu; + private const uint SliderControlRelativeId = 0x100002EEu; + private const uint SliderValueRelativeId = 0x100002EFu; + + private sealed record SliderWidgets(UiButton? Lock, UiScrollbar? Slider, UiField? Value); + + private readonly CharacterCreationRuntimeBindings _bindings; + private readonly Dictionary _templateButtons = []; + private readonly Dictionary _sliders = []; + private readonly UiButton? _availableValue; + private readonly UiButton? _healthValue; + private readonly UiButton? _staminaValue; + private readonly UiButton? _manaValue; + private 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); + } + + _sliders[attribute] = new SliderWidgets(lockButton, slider, value); + } + + // Live-DAT probe (CharacterCreationLiveDatTests): every one of the + // four display containers (0x100003e2..e5) authors as a Button + // whose Type-12 value child (0x100002f1/0x100002f3) is swallowed by + // UiButton.ConsumesDatChildren — the same "consumed child -> use + // the button's own Label" substitution the Skills page's credits + // meter needed (see CharacterCreationSkillsPage's ctor comment). + // Retail's own DynamicCast(0xc) on the CHILD (not the container) + // still stands as ground truth for the container's ROLE; only + // acdream's widget-level addressability differs (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; + } + + 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); + float scalar = (value - ChargenAttributeMath.AttributeMin) + / (float)(ChargenAttributeMath.AttributeMax - ChargenAttributeMath.AttributeMin); + 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); + } + + 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; + display.Label = value.ToString(CultureInfo.InvariantCulture); + } + + private void SelectTemplate(uint templateIndex) + { + if (_disposed) + return; + _bindings.SelectTemplate(templateIndex); + } + + private void SetAttributeFromScalar(ChargenAttributeId attribute, float scalar) + { + if (_disposed) + return; + int value = ChargenAttributeMath.AttributeMin + + (int)MathF.Round( + scalar * (ChargenAttributeMath.AttributeMax - ChargenAttributeMath.AttributeMin), + MidpointRounding.AwayFromZero); + _bindings.SetAttribute(attribute, value); + } + + /// Direct numeric entry via the value field's NumberInputFilter + /// (retail @0x00482e36) — an unparsable/empty submission is a no-op + /// rather than clamping to a guessed default. + private void SetAttributeFromText(ChargenAttributeId attribute, string text) + { + if (_disposed) + return; + if (int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out int value)) + _bindings.SetAttribute(attribute, value); + } + + private void ToggleLock(ChargenAttributeId attribute) + { + if (_disposed) + return; + RuntimeCharacterCreationSnapshot? snapshot = _bindings.View()?.Snapshot; + bool currentlyLocked = snapshot?.IsAttributeLocked(attribute) ?? false; + _bindings.SetAttributeLock(attribute, !currentlyLocked); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + foreach (UiButton button in _templateButtons.Keys) + button.OnClick = null; + _templateButtons.Clear(); + foreach (SliderWidgets widgets in _sliders.Values) + { + if (widgets.Lock is { } lockButton) + lockButton.OnClick = null; + if (widgets.Slider is { } slider) + slider.ScalarChanged = null; + if (widgets.Value is { } valueField) + valueField.OnSubmit = null; + } + _sliders.Clear(); + } +} diff --git a/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs new file mode 100644 index 00000000..8dcf2d43 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs @@ -0,0 +1,210 @@ +using System.Globalization; +using AcDream.Core.CharGen; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.App.UI.Layout; + +/// +/// The Skills page (gmCGSkillsPage, root 0x100003d3) — +/// simplified to one flat listbox rather than retail's four-bucket sorted +/// insertion model (InsertEntrySorted/UpdateSkillEntry, +/// Trained/Specialized/UseableUntrained/UnuseableUntrained — register +/// AP-213). Decomp +/// anchors: gmCGSkillsPage::InitializePage @ 0x00481dd0 (listbox +/// 0x100003f7, credits meter 0x100002f3 — imports as button +/// 0x100003f9's own consumed Label, see the ctor comment — info +/// panes 0x100003fb/0x100003fc), +/// gmCGSkillsPage::UpdateCreditsMeter +/// @ 0x004808f0 (credits display is the raw +/// remainingSkillCredits — no formula). The 16 skill ids uncostable +/// in BOTH the heritage's own list and the global SkillTable (retail's own +/// skills listbox never lists them either — CC1's +/// ChargenTableReaderInstalledDatTests) are filtered out via the +/// same two-tier presence check RuntimeCharacterCreationState's +/// TryGetSkillCost uses. +/// +internal sealed class CharacterCreationSkillsPage : IDisposable +{ + private readonly CharacterCreationRuntimeBindings _bindings; + private readonly UiTemplateListBox? _list; + private readonly UiButton? _credits; + private readonly UiText? _infoTitle; + private readonly UiText? _infoText; + private readonly List _rows = []; + private readonly Dictionary _rowSkillIds = []; + private uint _lastHeritageId; + private bool _rowsBuilt; + private bool _disposed; + + internal CharacterCreationSkillsPage( + UiElement pageRoot, + CharacterCreationRuntimeBindings bindings, + Func templateResolver) + { + _bindings = bindings; + _list = UiElement.FindDescendant(pageRoot, 0x100003F7u) as UiTemplateListBox; + if (_list is not null) + _list.TemplateResolver = templateResolver; + // 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). + _credits = UiElement.FindDescendant(pageRoot, 0x100003F9u) as UiButton; + _infoTitle = UiElement.FindDescendant(pageRoot, 0x100003FBu) as UiText; + _infoText = UiElement.FindDescendant(pageRoot, 0x100003FCu) as UiText; + } + + internal void Refresh( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + if (!_rowsBuilt || _lastHeritageId != snapshot.HeritageId) + { + RebuildRows(view, snapshot.HeritageId); + _lastHeritageId = snapshot.HeritageId; + _rowsBuilt = true; + } + + foreach (UiButton row in _rows) + { + if (!_rowSkillIds.TryGetValue(row, out uint skillId)) + continue; + row.Label = FormatSkillLabel(view, snapshot.HeritageId, skillId); + } + + if (_credits is { } credits) + credits.Label = snapshot.RemainingSkillCredits.ToString(CultureInfo.InvariantCulture); + } + + private void RebuildRows(IRuntimeCharacterCreationView view, uint heritageId) + { + foreach (UiButton row in _rows) + { + row.OnClick = null; + row.OnDoubleClick = null; + } + _rows.Clear(); + _rowSkillIds.Clear(); + _list?.Flush(); + + if (_list is null + || _list.Templates.Count == 0 + || _list.TemplateResolver is null + || !view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage)) + { + return; + } + + UiTemplateListEntry template = _list.Templates[0]; + for (uint skillId = 1; skillId < ChargenSkillAdvancementSet.SlotCount; skillId++) + { + if (!IsCostable(heritage, view.Options, skillId)) + continue; + if (_list.TemplateResolver(template.TemplateLayoutId, template.TemplateElementId) + is not UiButton row) + { + continue; + } + + _list.AddPrebuiltRow(row); + row.Enabled = true; + row.SuppressSelfToggle = true; + uint capturedSkillId = skillId; + row.OnClick = () => Advance(capturedSkillId); + row.OnDoubleClick = () => Retreat(capturedSkillId); + _rows.Add(row); + _rowSkillIds[row] = skillId; + } + } + + /// Same dictionary-presence gate as + /// RuntimeCharacterCreationState.TryGetSkillCost — heritage list + /// first, global SkillTable fallback. + private static bool IsCostable( + ChargenHeritageOptions heritage, + ChargenOptions options, + uint skillId) => + heritage.SkillCostsBySkillId.ContainsKey(skillId) + || options.GlobalSkillCostsBySkillId.ContainsKey(skillId); + + private string FormatSkillLabel( + IRuntimeCharacterCreationView view, + uint heritageId, + uint skillId) + { + string name = ItemAppraisalTextFormatter.SkillName((int)skillId); + ChargenSkillAdvancementClass level = view.GetSkillLevel(skillId); + (int trainedCost, int specializedCost) = GetCosts(view, heritageId, skillId); + return string.Create( + CultureInfo.InvariantCulture, + $"{name}: {level} (T{trainedCost}/S{specializedCost})"); + } + + 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); + } + + /// OnClick: one step up (Untrained/Inactive -> Trained, + /// Trained -> Specialized). Simplified from retail's separate + /// Increase/Decrease affordances (IncreaseSkillLevel/ + /// DecreaseSkillLevel) to one click target per row. + 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); + } + + /// OnDoubleClick: one step down (Specialized -> Trained, + /// Trained -> Untrained). + private void Retreat(uint skillId) + { + if (_disposed) + return; + ChargenSkillAdvancementClass level = _bindings.View()?.GetSkillLevel(skillId) + ?? ChargenSkillAdvancementClass.Inactive; + if (level == ChargenSkillAdvancementClass.Specialized) + _bindings.TrainSkill(skillId); + else if (level == ChargenSkillAdvancementClass.Trained) + _bindings.UntrainSkill(skillId); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + foreach (UiButton row in _rows) + { + row.OnClick = null; + row.OnDoubleClick = null; + } + _rows.Clear(); + _rowSkillIds.Clear(); + _list?.Flush(); + if (_list is not null) + _list.TemplateResolver = null; + } +} diff --git a/src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs new file mode 100644 index 00000000..4aeadb51 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs @@ -0,0 +1,121 @@ +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.App.UI.Layout; + +/// +/// The Town page (gmCGTownPage, root 0x100003d5) — the four +/// starting-area buttons. Decomp anchors: +/// gmCGTownPage::InitializePage @ 0x0047c6d0 (button ids), +/// gmCGTownPage::SetTown @ 0x0047c360 (button -> +/// CharGenState::SetStartArea(arg2 - 1) literal index map: Holtburg +/// -> 0, Shoushi -> 1, Yaraq -> 2, Sanamar -> 3), +/// gmCGTownPage::ListenToElementMessage @ 0x0047c480 (Sanamar's +/// AccountHasThroneOfDestiny gate — acdream has no account/DLC +/// signal, so it ships without the gate; register AD-102, same row as the +/// Heritage page's Viamontian gate), gmCGTownPage::SetTownString @ +/// 0x0047c1f0 (composed description text). +/// +internal sealed class CharacterCreationTownPage : IDisposable +{ + /// Button element id -> the LITERAL startArea index + /// gmCGTownPage::SetTown sends — retail hardcodes these four + /// indices directly rather than looking them up by name, so this port + /// does too. + private static readonly IReadOnlyDictionary StartAreaByButtonId = + new Dictionary + { + [0x1000040Du] = 0, // Holtburg + [0x1000040Fu] = 1, // Shoushi + [0x1000040Eu] = 2, // Yaraq + [0x1000040Bu] = 3, // Sanamar (ToD-gated in retail; see class doc) + }; + + private static readonly IReadOnlyDictionary TownTextKeyByStartArea = + new Dictionary + { + [0] = "ID_CharGen_HoltText", + [1] = "ID_CharGen_ShoushiText", + [2] = "ID_CharGen_YaraqText", + [3] = "ID_CharGen_SanamarText", + }; + + private readonly CharacterCreationRuntimeBindings _bindings; + private readonly Dictionary _buttons = []; + private readonly UiText? _description; + private bool _disposed; + + internal CharacterCreationTownPage( + UiElement pageRoot, + CharacterCreationRuntimeBindings bindings) + { + _bindings = bindings; + 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 (_description is null) + return; + + string composed = ComposeDescription(snapshot.StartArea, _bindings.ResolveText); + _description.LinesProvider = () => + [new UiText.Line(composed, _description.DefaultColor)]; + } + + internal void Randomize(IRuntimeCharacterCreationView view) + { + // CharGenState::SetStartArea(RandInt(hasToD ? 4 : 3)) — acdream + // always treats ToD as owned (see the class doc's AD-102 note), so + // this picks uniformly across all 4 literal indices (register + // AP-212 for the Random approximation itself), clamped to however + // many starter areas the installed DAT actually carries. + int bound = Math.Min(4, view.Options.StarterAreas.Count); + if (bound <= 0) + return; + Select(Random.Shared.Next(bound)); + } + + private void Select(int startArea) + { + if (_disposed) + return; + _bindings.SelectStartArea(startArea); + } + + private static string ComposeDescription(int startArea, Func? resolveText) + { + if (resolveText is null) + return string.Empty; + string? howTo = resolveText("ID_CharGen_TownHowTo"); + string? townText = TownTextKeyByStartArea.TryGetValue(startArea, out string? key) + ? resolveText(key) + : null; + if (howTo is null && townText is null) + return string.Empty; + return $"{howTo}\n\n{townText}\n"; + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + foreach (UiButton button in _buttons.Keys) + button.OnClick = null; + _buttons.Clear(); + } +} diff --git a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs new file mode 100644 index 00000000..0ef76c20 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs @@ -0,0 +1,650 @@ +using System.Numerics; +using AcDream.Core.CharGen; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.App.UI.Layout; + +/// +/// Bindings the retail character-creation screen (gmCharGenMainUI) +/// needs beyond the borrowed view: generation-capturing command wrappers, +/// mirroring 's shape exactly. +/// Every Func here is a late-bound seam (Campaign CC — see +/// feedback_resolve_deferred_funcs_per_call.md): callers MUST resolve +/// it per-call, never capture the delegate once at mount time. +/// +/// Campaign CC slice CC4: the retail +/// transition is Create Character (0x100003A0) → +/// QueueUIMode(0x1000000b), but that button stays ghosted until CC7's +/// closing move. This flag is the interim env/test-only open seam +/// (ACDREAM_OPEN_CHARGEN=1) +/// so the screen can be exercised before the real button is wired. +public sealed record CharacterCreationRuntimeBindings( + Func View, + Func SelectHeritage, + Func SelectGender, + Func SelectTemplate, + Func SetAttribute, + Func SetAttributeLock, + Func TrainSkill, + Func SpecializeSkill, + Func UntrainSkill, + Func SelectStartArea, + Func Finish, + Action RequestExit, + /// DAT string lookup (table 0x23000002, the SAME table + /// every other ID_CharGen_*/ID_Character* key resolves + /// through) — used by the Heritage page's composed description text. + /// degrades to the heritage's own DAT + /// Name field instead of the full composed copy. + Func? ResolveText = null, + bool OpenOnStart = false); + +/// +/// Projects Runtime's borrowed +/// through retail gmCharGenMainUI's authored retained layout — the +/// mount + master shell (progress bar, tab strip, Back/Next/Finish/Help/ +/// Exit/Random nav) plus the Heritage/Profession/Skills/Town pages this +/// slice builds. The Appearance (0x100003d4) and Summary +/// (0x100003d6) page roots are mounted but content-inert — CC6/CC5 +/// fill them (register TS-82). +/// +/// +/// Decomp anchors: root construction + child resolution +/// gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0 (root element +/// 0x100003cc from enum 0x10000039); page switching +/// gmCharGenMainUI::SetProgressState @ 0x004e7a10 (the Olthoi +/// tab-hiding + redirect logic); nav dispatch +/// gmCharGenMainUI::ListenToElementMessage @ 0x004e9450; exit +/// confirmation gmCharGenMainUI::DoExit @ 0x004e8650; randomize +/// dispatch gmCharGenMainUI::DoRandom @ 0x004e7d70. +/// +/// +internal sealed class CharacterCreationUiController : IDisposable +{ + internal const uint RootEnum = 0x10000039u; + internal const uint RootElementId = 0x100003CCu; + internal const uint ProgressBarElementId = 0x100003CEu; + internal const uint BackElementId = 0x100003C6u; + internal const uint NextElementId = 0x100003C7u; + internal const uint FinishElementId = 0x100003C8u; + internal const uint HelpElementId = 0x100003C9u; + internal const uint ExitElementId = 0x100003CAu; + internal const uint RandomElementId = 0x100003CBu; + internal const uint MasterPageElementId = 0x100003D0u; + internal const uint HeritagePageElementId = 0x100003D1u; + internal const uint ProfessionPageElementId = 0x100003D2u; + internal const uint SkillsPageElementId = 0x100003D3u; + internal const uint AppearancePageElementId = 0x100003D4u; + internal const uint TownPageElementId = 0x100003D5u; + internal const uint SummaryPageElementId = 0x100003D6u; + internal const uint HeritageTabElementId = 0x100003EFu; + internal const uint ProfessionTabElementId = 0x100003F0u; + internal const uint SkillsTabElementId = 0x100003F1u; + internal const uint AppearanceTabElementId = 0x100003F2u; + internal const uint TownTabElementId = 0x100003F3u; + internal const uint SummaryTabElementId = 0x100003F4u; + + /// Retail's gmCharGenMainUI::ECGProgress enum values — + /// used verbatim as the master page's per-page state ids + /// (0x10000025 + (page - 1)) and the tab-hide/redirect math in + /// . + internal enum Page + { + Heritage = 1, + Profession = 2, + Skills = 3, + Appearance = 4, + Town = 5, + Summary = 6, + } + + internal sealed record DialogStrings(string ExitWarning); + + private readonly UiRoot _host; + private readonly ImportedLayout _layout; + private readonly UiElement _progressBar; + private readonly UiButton _back; + private readonly UiButton _next; + private readonly UiButton _finish; + private readonly UiButton _help; + private readonly UiButton _exit; + private readonly UiButton _random; + private readonly UiElement _masterPage; + private readonly UiElement _heritagePageRoot; + private readonly UiElement _professionPageRoot; + private readonly UiElement _skillsPageRoot; + private readonly UiElement _appearancePageRoot; + private readonly UiElement _townPageRoot; + private readonly UiElement _summaryPageRoot; + private readonly UiButton _heritageTab; + private readonly UiButton _professionTab; + private readonly UiButton _skillsTab; + private readonly UiButton _appearanceTab; + private readonly UiButton _townTab; + private readonly UiButton _summaryTab; + private readonly RetailDialogFactory _dialogs; + private readonly CharacterCreationRuntimeBindings _bindings; + private readonly DialogStrings _strings; + private readonly CharacterCreationHeritagePage _heritagePage; + private readonly CharacterCreationProfessionPage _professionPage; + private readonly CharacterCreationSkillsPage _skillsPage; + private readonly CharacterCreationTownPage _townPage; + + private Vector2 _authoredCanvas; + private RuntimeGenerationToken _lastGeneration; + private long _lastRevision = long.MinValue; + private Page _currentPage = Page.Heritage; + private bool _active; + private bool _isOpen; + private bool _openOnStartConsumed; + private uint _exitDialogContext; + private bool _suppressDialogCallbacks; + private bool _disposed; + + private CharacterCreationUiController( + UiRoot host, + ImportedLayout layout, + UiElement progressBar, + UiButton back, + UiButton next, + UiButton finish, + UiButton help, + UiButton exit, + UiButton random, + UiElement masterPage, + UiElement heritagePageRoot, + UiElement professionPageRoot, + UiElement skillsPageRoot, + UiElement appearancePageRoot, + UiElement townPageRoot, + UiElement summaryPageRoot, + UiButton heritageTab, + UiButton professionTab, + UiButton skillsTab, + UiButton appearanceTab, + UiButton townTab, + UiButton summaryTab, + Func templateResolver, + RetailDialogFactory dialogs, + CharacterCreationRuntimeBindings bindings, + DialogStrings strings) + { + _host = host; + _layout = layout; + _progressBar = progressBar; + _back = back; + _next = next; + _finish = finish; + _help = help; + _exit = exit; + _random = random; + _masterPage = masterPage; + _heritagePageRoot = heritagePageRoot; + _professionPageRoot = professionPageRoot; + _skillsPageRoot = skillsPageRoot; + _appearancePageRoot = appearancePageRoot; + _townPageRoot = townPageRoot; + _summaryPageRoot = summaryPageRoot; + _heritageTab = heritageTab; + _professionTab = professionTab; + _skillsTab = skillsTab; + _appearanceTab = appearanceTab; + _townTab = townTab; + _summaryTab = summaryTab; + _dialogs = dialogs; + _bindings = bindings; + _strings = strings; + + Root.Left = 0f; + Root.Top = 0f; + Root.ClickThrough = false; + Root.Visible = false; + // AD-98: the same authored 800x600 fixed-canvas treatment as the + // character-management screen — see that controller's own comment. + // Both screens author the identical extent, so it is safe for both + // controllers to independently (idempotently) push the SAME value + // to the shared UiRoot.FixedCanvasSize; this controller therefore + // never NULLS it back out on close (see Deactivate/Close), leaving + // char-management's own per-tick set as the surviving owner once + // this screen is not the active one. + _authoredCanvas = new Vector2( + Root.Width > 0f ? Root.Width : 800f, + Root.Height > 0f ? Root.Height : 600f); + + _heritagePage = new CharacterCreationHeritagePage(heritagePageRoot, bindings); + _professionPage = new CharacterCreationProfessionPage(professionPageRoot, bindings); + _skillsPage = new CharacterCreationSkillsPage(skillsPageRoot, bindings, templateResolver); + _townPage = new CharacterCreationTownPage(townPageRoot, bindings); + + // gmCharGenMainUI::ListenToElementMessage @ 0x004e9450. + _back.OnClick = OnBack; + _next.OnClick = OnNext; + // Finish (0x100003c8) stays ghosted this round: Summary + // (0x100003d6) is CC5's placeholder, and DoFinish's real gate + // sequence lives in RuntimeCharacterCreationState.TryBeginFinish — + // wiring the button here without a Summary page to confirm/collect + // the name would let a click reach the wire with an empty name and + // silently refuse. No OnClick handler; _finish.Enabled stays false + // (see ApplyProgressState). + _finish.OnClick = null; + // Help (0x100003c9) is not handled in gmCharGenMainUI's own + // ListenToElementMessage switch (case 0x100003c9 falls straight + // through to the base UIFramework handler) — retail has no custom + // help action here either; leave it a no-op. + _help.OnClick = null; + _exit.OnClick = OnExit; + _random.OnClick = OnRandom; + _heritageTab.OnClick = () => ApplyProgressState(Page.Heritage); + _professionTab.OnClick = () => ApplyProgressState(Page.Profession); + _skillsTab.OnClick = () => ApplyProgressState(Page.Skills); + _appearanceTab.OnClick = () => ApplyProgressState(Page.Appearance); + _townTab.OnClick = () => ApplyProgressState(Page.Town); + _summaryTab.OnClick = () => ApplyProgressState(Page.Summary); + } + + internal UiElement Root => _layout.Root; + + internal static CharacterCreationUiController? CreateDetached( + UiRoot host, + ImportedLayout layout, + Func templateResolver, + RetailDialogFactory dialogs, + CharacterCreationRuntimeBindings bindings, + DialogStrings strings) + { + ArgumentNullException.ThrowIfNull(host); + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(templateResolver); + ArgumentNullException.ThrowIfNull(dialogs); + ArgumentNullException.ThrowIfNull(bindings); + ArgumentNullException.ThrowIfNull(strings); + + if (layout.Root.DatElementId != RootElementId + || layout.FindElement(ProgressBarElementId) is not { } progressBar + || layout.FindElement(BackElementId) is not UiButton back + || layout.FindElement(NextElementId) is not UiButton next + || layout.FindElement(FinishElementId) is not UiButton finish + || layout.FindElement(HelpElementId) is not UiButton help + || layout.FindElement(ExitElementId) is not UiButton exit + || layout.FindElement(RandomElementId) is not UiButton random + || layout.FindElement(MasterPageElementId) is not { } masterPage + || layout.FindElement(HeritagePageElementId) is not { } heritagePageRoot + || layout.FindElement(ProfessionPageElementId) is not { } professionPageRoot + || layout.FindElement(SkillsPageElementId) is not { } skillsPageRoot + || layout.FindElement(AppearancePageElementId) is not { } appearancePageRoot + || layout.FindElement(TownPageElementId) is not { } townPageRoot + || layout.FindElement(SummaryPageElementId) is not { } summaryPageRoot + || layout.FindElement(HeritageTabElementId) is not UiButton heritageTab + || layout.FindElement(ProfessionTabElementId) is not UiButton professionTab + || layout.FindElement(SkillsTabElementId) is not UiButton skillsTab + || layout.FindElement(AppearanceTabElementId) is not UiButton appearanceTab + || layout.FindElement(TownTabElementId) is not UiButton townTab + || layout.FindElement(SummaryTabElementId) is not UiButton summaryTab) + { + Console.WriteLine( + "[UI] character creation: the authored root/master-shell contract is incomplete."); + return null; + } + + return new CharacterCreationUiController( + host, + layout, + progressBar, + back, + next, + finish, + help, + exit, + random, + masterPage, + heritagePageRoot, + professionPageRoot, + skillsPageRoot, + appearancePageRoot, + townPageRoot, + summaryPageRoot, + heritageTab, + professionTab, + skillsTab, + appearanceTab, + townTab, + summaryTab, + templateResolver, + dialogs, + bindings, + strings); + } + + internal void AttachAndTick() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (Root.Parent is null) + _host.AddChild(Root); + Tick(); + } + + internal void Tick() + { + if (_disposed) + return; + + IRuntimeCharacterCreationView? view = _bindings.View(); + RuntimeCharacterCreationSnapshot snapshot = view?.Snapshot ?? default; + if (view is null || !snapshot.IsActive) + { + Deactivate(); + _lastGeneration = snapshot.Generation; + _lastRevision = snapshot.Revision; + return; + } + + if (!_active) + { + _active = true; + // CC4 interim open seam (ACDREAM_OPEN_CHARGEN=1) — the real + // Create-button transition is CC7's. Fires once per mount. + if (_bindings.OpenOnStart && !_openOnStartConsumed) + { + _openOnStartConsumed = true; + Open(); + } + } + + if (_isOpen) + { + Root.Visible = true; + _host.FixedCanvasSize = _authoredCanvas; + _host.BringToFront(Root); + } + else + { + Root.Visible = false; + } + + if (_lastGeneration != snapshot.Generation + || _lastRevision != snapshot.Revision) + { + _heritagePage.Refresh(view, snapshot); + _professionPage.Refresh(view, snapshot); + _skillsPage.Refresh(view, snapshot); + _townPage.Refresh(view, snapshot); + _lastGeneration = snapshot.Generation; + _lastRevision = snapshot.Revision; + } + + ReconcileDialogs(snapshot); + } + + /// Opens the screen at retail's authored default page + /// (gmCharGenMainUI::gmCharGenMainUI's trailing + /// SetProgressState(this, ECG_HERTAGE)). + internal void Open() + { + if (_disposed) + return; + _isOpen = true; + ApplyProgressState(Page.Heritage); + } + + private void Close() + { + _isOpen = false; + Root.Visible = false; + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + try + { + CloseAllDialogs(suppressCallbacks: true); + } + finally + { + _back.OnClick = null; + _next.OnClick = null; + _finish.OnClick = null; + _help.OnClick = null; + _exit.OnClick = null; + _random.OnClick = null; + _heritageTab.OnClick = null; + _professionTab.OnClick = null; + _skillsTab.OnClick = null; + _appearanceTab.OnClick = null; + _townTab.OnClick = null; + _summaryTab.OnClick = null; + _heritagePage.Dispose(); + _professionPage.Dispose(); + _skillsPage.Dispose(); + _townPage.Dispose(); + _host.RemoveChild(Root); + } + } + + // ── Nav dispatch (gmCharGenMainUI::ListenToElementMessage @ 0x004e9450) ── + + private void OnBack() + { + if (_disposed) + return; + if (_currentPage <= Page.Heritage) + { + OnExit(); + return; + } + ApplyProgressState(_currentPage - 1); + } + + private void OnNext() + { + if (_disposed) + return; + if (_currentPage < Page.Summary) + ApplyProgressState(_currentPage + 1); + } + + private void OnExit() + { + if (_disposed) + return; + // gmCharGenMainUI::DoExit @ 0x004e8650's own guard: a second Exit + // click while the confirmation is already open is a no-op. + if (_exitDialogContext != 0u) + return; + + _exitDialogContext = _dialogs.MakeConfirmation( + _strings.ExitWarning, + data => + { + _exitDialogContext = 0u; + if (_disposed || _suppressDialogCallbacks) + return; + + // RecvNotice_CloseDialog @ 0x004e9780's exit-context branch: + // confirm -> QueueUIMode(0x1000000a) (leave chargen). Our + // equivalent is closing this screen; whatever mounted the + // character-management screen already keeps re-drawing it + // underneath (this screen only BringToFront's itself while + // open — see Tick). + if (data.GetBoolean(RetailDialogProperty.ConfirmationResult)) + { + Close(); + _bindings.RequestExit(); + } + }); + } + + private void OnRandom() + { + if (_disposed) + return; + + // gmCharGenMainUI::DoRandom @ 0x004e7d70. Heritage/Profession/Town + // are ported below; Skills' CharGenState::RandomizeSkills and the + // Summary randomize-warning dialog have no CC3 primitive/page yet + // this round — register AP-212 covers both gaps, and _random.Enabled + // already keeps the control ghosted on those pages (ApplyProgressState). + IRuntimeCharacterCreationView? view = _bindings.View(); + if (view is null) + return; + RuntimeCharacterCreationSnapshot snapshot = view.Snapshot; + + switch (_currentPage) + { + case Page.Heritage: + _heritagePage.Randomize(snapshot); + break; + case Page.Profession: + _professionPage.Randomize(snapshot); + break; + case Page.Town: + _townPage.Randomize(view); + break; + } + } + + // ── Page switching (gmCharGenMainUI::SetProgressState @ 0x004e7a10) ──── + + private void ApplyProgressState(Page target) + { + _heritagePageRoot.Visible = false; + _professionPageRoot.Visible = false; + _skillsPageRoot.Visible = false; + _appearancePageRoot.Visible = false; + _townPageRoot.Visible = false; + _summaryPageRoot.Visible = false; + _next.Visible = true; + _finish.Visible = false; + + Page previous = _currentPage; + _currentPage = target; + _heritageTab.Selected = false; + _professionTab.Selected = false; + _skillsTab.Selected = false; + _appearanceTab.Selected = false; + _townTab.Selected = false; + _summaryTab.Selected = false; + + uint heritageId = _bindings.View()?.Snapshot.HeritageId ?? 0u; + bool isOlthoi = heritageId == (uint)ChargenHeritageGroup.Olthoi + || heritageId == (uint)ChargenHeritageGroup.OlthoiAcid; + if (isOlthoi) + { + _professionTab.Visible = false; + _skillsTab.Visible = false; + _townTab.Visible = false; + if (_currentPage < previous) + { + if (_currentPage is Page.Profession or Page.Skills) + _currentPage = Page.Heritage; + else if (_currentPage == Page.Town) + _currentPage = Page.Appearance; + } + else + { + if (_currentPage is Page.Profession or Page.Skills) + _currentPage = Page.Appearance; + else if (_currentPage == Page.Town) + _currentPage = Page.Summary; + } + } + else + { + _professionTab.Visible = true; + _skillsTab.Visible = true; + _townTab.Visible = true; + } + + SetMasterPageState(0x10000025u + (uint)_currentPage - 1u); + switch (_currentPage) + { + case Page.Heritage: + _heritagePageRoot.Visible = true; + _heritageTab.Selected = true; + break; + case Page.Profession: + _professionPageRoot.Visible = true; + _professionTab.Selected = true; + break; + case Page.Skills: + _skillsPageRoot.Visible = true; + _skillsTab.Selected = true; + break; + case Page.Appearance: + _appearancePageRoot.Visible = true; + _appearanceTab.Selected = true; + break; + case Page.Town: + _townPageRoot.Visible = true; + _townTab.Selected = true; + break; + case Page.Summary: + _summaryPageRoot.Visible = true; + _summaryTab.Selected = true; + _next.Visible = false; + _finish.Visible = true; + break; + } + + // Random (0x100003cb): retail refuses on Skills (no + // RandomizeSkills primitive ported — AP-212) and on Summary + // (MakeRandomizeWarningDialog is CC5's); Appearance is this round's + // placeholder. + _random.Enabled = _currentPage + is not (Page.Skills or Page.Appearance or Page.Summary); + // Finish stays ghosted regardless of page — Summary is a + // placeholder this round (see the ctor comment on _finish.OnClick). + _finish.Enabled = false; + + _lastRevision = long.MinValue; + Tick(); + } + + private void SetMasterPageState(uint stateId) + { + if (_masterPage is IUiDatStateful stateful) + stateful.TrySetRetailState(stateId); + } + + private void ReconcileDialogs(RuntimeCharacterCreationSnapshot snapshot) + { + // Local-refusal / rejection surfacing is CC5's Summary-page job + // (the Finish gate only fires from that page). This round only + // needs the exit-confirmation dialog reconciled against disposal. + _ = snapshot; + } + + private void Deactivate() + { + if (_active) + { + _active = false; + _isOpen = false; + _openOnStartConsumed = false; + Root.Visible = false; + } + CloseAllDialogs(suppressCallbacks: true); + } + + private void CloseAllDialogs(bool suppressCallbacks) + { + bool previous = _suppressDialogCallbacks; + _suppressDialogCallbacks |= suppressCallbacks; + try + { + if (_exitDialogContext != 0u) + { + uint closing = _exitDialogContext; + _exitDialogContext = 0u; + _dialogs.CloseDialog(closing); + } + } + finally + { + _suppressDialogCallbacks = previous; + } + } +} diff --git a/src/AcDream.App/UI/Layout/CharacterCreationUiMountCoordinator.cs b/src/AcDream.App/UI/Layout/CharacterCreationUiMountCoordinator.cs new file mode 100644 index 00000000..a6d83b45 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterCreationUiMountCoordinator.cs @@ -0,0 +1,99 @@ +namespace AcDream.App.UI.Layout; + +internal sealed record CharacterCreationUiMountResources( + uint LayoutId, + ImportedLayout Layout, + Func TemplateResolver, + CharacterCreationUiController.DialogStrings Strings); + +/// +/// Retryable, idempotent composition edge for the character-creation screen — +/// clone of 's recipe. DATs +/// can become readable after the graphical runtime starts, so an unavailable +/// dialog catalog, root, or string must not permanently suppress the screen. +/// +internal sealed class CharacterCreationUiMountCoordinator : IDisposable +{ + private readonly UiRoot _host; + private readonly CharacterCreationRuntimeBindings _bindings; + private readonly Func _ensureDialogs; + private readonly Func _loadResources; + private bool _disposed; + + public CharacterCreationUiMountCoordinator( + UiRoot host, + CharacterCreationRuntimeBindings bindings, + Func ensureDialogs, + Func loadResources) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _bindings = bindings ?? throw new ArgumentNullException(nameof(bindings)); + _ensureDialogs = ensureDialogs + ?? throw new ArgumentNullException(nameof(ensureDialogs)); + _loadResources = loadResources + ?? throw new ArgumentNullException(nameof(loadResources)); + } + + public CharacterCreationUiController? Controller { get; private set; } + + public void Tick() + { + if (_disposed || Controller is not null) + return; + + try + { + RetailDialogFactory? dialogs = _ensureDialogs(); + if (dialogs is null) + return; + + CharacterCreationUiMountResources? resources = _loadResources(); + if (resources is null) + return; + + CharacterCreationUiController? candidate = + CharacterCreationUiController.CreateDetached( + _host, + resources.Layout, + resources.TemplateResolver, + dialogs, + _bindings, + resources.Strings); + if (candidate is null) + return; + + Controller = candidate; + candidate.AttachAndTick(); + Console.WriteLine( + $"[UI] retail character creation from enum table 5 " + + $"(0x10000039 -> 0x{resources.LayoutId:X8}, root 0x100003CC)."); + } + catch (Exception error) + { + CharacterCreationUiController? partial = Controller; + Controller = null; + try + { + partial?.Dispose(); + } + catch (Exception cleanupError) + { + Console.WriteLine( + "[UI] character creation partial-mount cleanup failed: " + + cleanupError.Message); + } + Console.WriteLine( + "[UI] character creation mount will retry after resource " + + $"recovery: {error.Message}"); + } + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + Controller?.Dispose(); + Controller = null; + } +} diff --git a/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs b/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs index 82400411..36e628b8 100644 --- a/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs +++ b/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs @@ -1716,7 +1716,10 @@ public static class ItemAppraisalTextFormatter }; /// AppraisalSystem::SkillToString @ 0x005B4A30. - private static string SkillName(int skill) => skill switch + /// Retail skill-id -> display-name table. Made internal + /// (Campaign CC slice CC4) so the chargen Skills page can reuse the + /// same names instead of duplicating this table. + internal static string SkillName(int skill) => skill switch { 1 => "Axe", 2 => "Bow", diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 91c559c9..4adbb823 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -427,7 +427,9 @@ public sealed record RetailUiRuntimeBindings( RetailUiPersistenceBindings? Persistence, RetailUiProbeBindings Probe, KeyboardRuntimeBindings? Keyboard = null, - CharacterSelectionRuntimeBindings? CharacterSelection = null); + CharacterSelectionRuntimeBindings? CharacterSelection = null, + // Campaign CC slice CC4: sibling of CharacterSelection above. + CharacterCreationRuntimeBindings? CharacterCreation = null); /// /// Composition owner for the production retained gameplay UI. GameWindow supplies @@ -450,6 +452,7 @@ public sealed class RetailUiRuntime : IDisposable private ItemCooldownUiController? _itemCooldownController; private VividTargetIndicatorController? _vividTargetIndicator; private CharacterManagementUiMountCoordinator? _characterManagementMount; + private CharacterCreationUiMountCoordinator? _characterCreationMount; private IDisposable? _characterSheetSubscription; private ResourceShutdownTransaction? _shutdown; private bool _disposed; @@ -518,6 +521,8 @@ public sealed class RetailUiRuntime : IDisposable MountItemCooldowns(); ConfigureCharacterManagement(); _characterManagementMount?.Tick(); + ConfigureCharacterCreation(); + _characterCreationMount?.Tick(); Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged; BindToolbarPanelButtons(); SyncToolbarWindowButtons(); @@ -614,6 +619,8 @@ public sealed class RetailUiRuntime : IDisposable public SocialPanelController? SocialPanelController { get; private set; } internal CharacterManagementUiController? CharacterManagementController => _characterManagementMount?.Controller; + internal CharacterCreationUiController? CharacterCreationController => + _characterCreationMount?.Controller; public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings) { @@ -661,6 +668,8 @@ public sealed class RetailUiRuntime : IDisposable _itemCooldownController?.Tick(); _characterManagementMount?.Tick(); CharacterManagementController?.Tick(); + _characterCreationMount?.Tick(); + CharacterCreationController?.Tick(); DialogFactory?.Tick(); Host.Tick(deltaSeconds); _automation?.Tick(deltaSeconds); @@ -3875,6 +3884,87 @@ public sealed class RetailUiRuntime : IDisposable 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; + lock (_bindings.Assets.DatLock) + { + exitWarning = ResolveCharacterManagementString( + strings, + stringTableId, + "ID_CharGen_ExitWarning"); + } + if (exitWarning 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)); + } + private void MountItemCooldowns() { ItemCooldownAssets? assets; @@ -3921,6 +4011,7 @@ public sealed class RetailUiRuntime : IDisposable () => { _characterManagementMount?.Dispose(); + _characterCreationMount?.Dispose(); _gameplayConfirmationController?.Dispose(); }, () => DialogFactory?.Dispose(), diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 21b168f3..8f0735bd 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -409,7 +409,18 @@ internal sealed class HeadlessSessionHost : IDisposable descriptor.Id, selection.CharacterId, selection.CharacterName), - loginCommands)); + loginCommands, + // Campaign CC slice CC4: same status-parity wiring as + // the graphical host (LiveSessionRuntimeFactory.Create). + CharacterCreated: identity => statusWriter.CharacterCreated( + descriptor.Id, + identity.Guid, + identity.Name), + CreationFailed: rejection => statusWriter.CreationFailed( + descriptor.Id, + rejection.RawCode, + rejection.Reason, + rejection.AttemptedName))); Runtime = runtime; Commands = commands; diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs index fadf5bd9..3815b305 100644 --- a/src/AcDream.Runtime/GameRuntime.cs +++ b/src/AcDream.Runtime/GameRuntime.cs @@ -533,6 +533,8 @@ public sealed class GameRuntime public IRuntimeChatView Chat => CommunicationOwner.View; public IRuntimeCharacterSelectionView CharacterSelection => Session.CharacterSelection; + public IRuntimeCharacterCreationView CharacterCreation => + Session.CharacterCreation; public IRuntimeFellowshipView Fellowship => FellowshipOwner.View; public IRuntimeAllegianceView Allegiance => AllegianceOwner.View; diff --git a/src/AcDream.Runtime/GameRuntimeViews.cs b/src/AcDream.Runtime/GameRuntimeViews.cs index ca973e34..3c9baf30 100644 --- a/src/AcDream.Runtime/GameRuntimeViews.cs +++ b/src/AcDream.Runtime/GameRuntimeViews.cs @@ -280,6 +280,12 @@ public interface IGameRuntimeView throw new NotSupportedException( "This runtime view does not project character selection."); + /// Campaign CC slice CC4: mirrors 's + /// default-throw shape. + IRuntimeCharacterCreationView CharacterCreation => + throw new NotSupportedException( + "This runtime view does not project character creation."); + IRuntimeFellowshipView Fellowship { get; } IRuntimeAllegianceView Allegiance { get; } diff --git a/src/AcDream.Runtime/Session/LiveSessionHost.cs b/src/AcDream.Runtime/Session/LiveSessionHost.cs index 1782f9d4..9d977419 100644 --- a/src/AcDream.Runtime/Session/LiveSessionHost.cs +++ b/src/AcDream.Runtime/Session/LiveSessionHost.cs @@ -41,7 +41,16 @@ public sealed record LiveSessionHostBindings( /// fan-out, this exists so a status writer can emit the /// enteredWorld event's characterId field. Action CharacterEntered, - LoginCommandSequence? LoginCommands = null); + LoginCommandSequence? LoginCommands = null, + /// Campaign CC slice CC4: forwards + /// — + /// hosts wire this to SessionStatusWriter.CharacterCreated. + /// Default no-op. + Action? CharacterCreated = null, + /// Campaign CC slice CC4: forwards + /// — hosts + /// wire this to SessionStatusWriter.CreationFailed. + Action? CreationFailed = null); /// /// Runtime host for the one canonical . @@ -136,7 +145,9 @@ public sealed class LiveSessionHost Connected: bindings.Connected, Roster: bindings.Roster, Selected: ApplySelection, - Entered: ApplyEnteredWorld)); + Entered: ApplyEnteredWorld, + CharacterCreated: bindings.CharacterCreated, + CreationFailed: bindings.CreationFailed)); } public WorldSession? CurrentSession => _controller.CurrentSession; diff --git a/src/AcDream.Runtime/Session/LiveSessionLifecycleHost.cs b/src/AcDream.Runtime/Session/LiveSessionLifecycleHost.cs index 7f1a7f92..c78db435 100644 --- a/src/AcDream.Runtime/Session/LiveSessionLifecycleHost.cs +++ b/src/AcDream.Runtime/Session/LiveSessionLifecycleHost.cs @@ -9,7 +9,15 @@ public sealed record LiveSessionLifecycleBindings( Action Connected, Action Roster, Action Selected, - Action Entered); + Action Entered, + /// Campaign CC slice CC4: forwards + /// . Default + /// no-op preserves every existing positional/named construction site + /// that predates this field. + Action? CharacterCreated = null, + /// Campaign CC slice CC4: forwards + /// . + Action? CreationFailed = null); /// /// Focused adapter between the session lifetime owner and its composition @@ -62,6 +70,12 @@ public sealed class LiveSessionLifecycleHost : ILiveSessionLifecycleHost public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) => _bindings.Entered(selection); + public void ApplyCharacterCreated(RuntimeCharacterCreationIdentity identity) => + _bindings.CharacterCreated?.Invoke(identity); + + public void ApplyCreationFailed(RuntimeCharacterCreationRejection rejection) => + _bindings.CreationFailed?.Invoke(rejection); + public void DetachSession(WorldSession session) { if (!ReferenceEquals(_boundSession, session)) diff --git a/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs b/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs index f38ee9ee..85696ced 100644 --- a/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs +++ b/src/AcDream.Runtime/Session/RuntimeCharacterCreationState.cs @@ -281,7 +281,7 @@ public sealed class RuntimeCharacterCreationState : IDisposable private readonly object _gate = new(); private readonly CharacterCreationEventStream _events = new(); private readonly ViewProjection _view; - private readonly ChargenOptions _options; + private ChargenOptions _options; private readonly Random _random; private RuntimeGenerationToken _generation; private bool _active; @@ -321,6 +321,39 @@ public sealed class RuntimeCharacterCreationState : IDisposable public ChargenOptions Options => _options; + /// + /// Campaign CC slice CC4: installs the real chargen options loaded from + /// the installed DAT (AcDream.Content.CharGen.ChargenTableReader.Load) + /// once the content host's DAT collection opens, mirroring the + /// established "install immutable DAT metadata after construction" + /// pattern (RuntimeCharacterState.InstallSpellMetadata -> + /// Spellbook.InstallMetadata). GameWindow constructs the + /// (and therefore this state, + /// defaulted to ) before portal.dat is + /// open; ContentEffectsAudioCompositionPhase.Compose calls this + /// once DATs are published, always well before + /// — no character-selection/creation session can be + /// active yet at that point in the composition sequence, so there is no + /// concurrent read to race. Throws if called while a session is already + /// active — a second install after chargen has started reading the + /// first one would be a genuine caller bug, not a case to silently + /// tolerate. + /// + public void InstallOptions(ChargenOptions options) + { + ArgumentNullException.ThrowIfNull(options); + lock (_gate) + { + ThrowIfDisposed(); + if (_active) + { + throw new InvalidOperationException( + "Chargen options cannot be installed while a character-creation session is active."); + } + _options = options; + } + } + public RuntimeCharacterCreationSnapshot Snapshot { get diff --git a/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs b/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs index 7b6de332..730c54ad 100644 --- a/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs @@ -10,6 +10,7 @@ using AcDream.App.Spells; using AcDream.Content; using AcDream.Content.Vfx; using AcDream.Core.Audio; +using AcDream.Core.CharGen; using AcDream.Core.Lighting; using AcDream.Core.Physics; using AcDream.Core.Rendering; @@ -17,6 +18,7 @@ using AcDream.Core.Spells; using AcDream.Core.Vfx; using AcDream.Runtime.Gameplay; using AcDream.Runtime.Physics; +using AcDream.Runtime.Session; using DatReaderWriter.DBObjs; using Silk.NET.Input; using Silk.NET.OpenAL; @@ -403,6 +405,11 @@ public sealed class ContentEffectsAudioCompositionTests RuntimeCharacterState character, MagicCatalog catalog) { } public int GetSpellCount(MagicCatalog catalog) => 0; + public ChargenOptions LoadChargenOptions(IDatReaderWriter dats) => + ChargenOptions.Empty; + public void InstallChargenOptions( + LiveSessionController session, + ChargenOptions options) { } public IAnimationLoader CreateAnimationLoader( IDatReaderWriter dats, long maximumEstimatedBytes, diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs new file mode 100644 index 00000000..cd1135a3 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs @@ -0,0 +1,334 @@ +using System.IO; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Content; +using DatReaderWriter; +using DatReaderWriter.Options; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Installed-retail-DAT acceptance gate for Campaign CC slice CC4. Opt in +/// with ACDREAM_PROBE_LIVE_MOUNT=1; ACDREAM_DAT_DIR can +/// override the ordinary Documents/Asheron's Call location. Mirrors +/// 's pattern: sweeps the +/// authored master-shell and page ids the campaign plan and CC4's own +/// decomp research cite, pinning them against the real installed layout. +/// +public sealed class CharacterCreationLiveDatTests +{ + private static string DatDirectory => + Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", + "Asheron's Call"); + + [InstalledDatFact] + public void EnumTable5_ResolvesTheMasterShellRootAndChildren() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + + uint layoutId = RetailDataIdResolver.Resolve( + dats, + CharacterCreationUiController.RootEnum, + 5u); + Assert.NotEqual(0u, layoutId); + Console.WriteLine( + $"[CC4-DAT] category=5 enum=0x10000039 -> DID=0x{layoutId:X8}"); + + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + Assert.Equal( + CharacterCreationUiController.RootElementId, + screen.Root.DatElementId); + + Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.ProgressBarElementId)); + AssertButton(screen, CharacterCreationUiController.BackElementId); + AssertButton(screen, CharacterCreationUiController.NextElementId); + AssertButton(screen, CharacterCreationUiController.FinishElementId); + AssertButton(screen, CharacterCreationUiController.HelpElementId); + AssertButton(screen, CharacterCreationUiController.ExitElementId); + AssertButton(screen, CharacterCreationUiController.RandomElementId); + Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.MasterPageElementId)); + foreach (uint pageId in new[] + { + CharacterCreationUiController.HeritagePageElementId, + CharacterCreationUiController.ProfessionPageElementId, + CharacterCreationUiController.SkillsPageElementId, + CharacterCreationUiController.AppearancePageElementId, + CharacterCreationUiController.TownPageElementId, + CharacterCreationUiController.SummaryPageElementId, + }) + { + Assert.IsAssignableFrom(screen.FindElement(pageId)); + } + AssertButton(screen, CharacterCreationUiController.HeritageTabElementId); + AssertButton(screen, CharacterCreationUiController.ProfessionTabElementId); + AssertButton(screen, CharacterCreationUiController.SkillsTabElementId); + AssertButton(screen, CharacterCreationUiController.AppearanceTabElementId); + AssertButton(screen, CharacterCreationUiController.TownTabElementId); + AssertButton(screen, CharacterCreationUiController.SummaryTabElementId); + } + + [InstalledDatFact] + public void MountsThroughTheControllerAgainstLiveResources() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, + CharacterCreationUiController.RootEnum, + 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + var host = new UiRoot(); + var dialogs = MakeDialogFactory(dats, host); + var bindings = new CharacterCreationRuntimeBindings( + () => null, + _ => default, + _ => default, + _ => default, + (_, _) => default, + (_, _) => default, + _ => default, + _ => default, + _ => default, + _ => default, + _ => default, + () => { }); + + UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId) => + LayoutImporter.Import( + dats, templateLayoutId, templateElementId, _ => (0u, 0, 0), null)?.Root; + + CharacterCreationUiController? controller = + CharacterCreationUiController.CreateDetached( + host, screen, ResolveTemplate, dialogs, bindings, + new CharacterCreationUiController.DialogStrings("Are you sure?")); + Assert.NotNull(controller); + controller!.AttachAndTick(); + controller.Dispose(); + dialogs.Dispose(); + } + + /// 13 heritage buttons, the description text, all present as + /// authored (Heritage page — gmCGHeritagePage::InitializePage @ + /// 0x00483a10). + [InstalledDatFact] + public void HeritagePage_HasAllThirteenRaceButtonsAndDescriptionText() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, + CharacterCreationUiController.RootEnum, + 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement heritageRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.HeritagePageElementId)); + + uint[] heritageButtonIds = + [ + 0x100003BFu, 0x100003C1u, 0x100003C2u, 0x100003C3u, + 0x10000590u, 0x100005A9u, 0x100005E8u, 0x100005F1u, + 0x100005C4u, 0x10000591u, 0x100005BFu, 0x100005C7u, + 0x100005C8u, + ]; + foreach (uint buttonId in heritageButtonIds) + { + Assert.IsType( + UiElement.FindDescendant(heritageRoot, buttonId)); + } + Assert.IsType( + UiElement.FindDescendant(heritageRoot, 0x100003C4u)); + } + + /// Seven template buttons, six attribute sliders (each with a + /// lock button + scrollbar + value text), and the four derived + /// displays (Profession page — + /// gmCGProfessionPage::InitializePage @ 0x00482d50). + [InstalledDatFact] + public void ProfessionPage_HasTemplateButtonsSlidersAndDisplays() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, + CharacterCreationUiController.RootEnum, + 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement professionRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.ProfessionPageElementId)); + + uint[] templateButtonIds = + [ + 0x100003D9u, 0x100003DAu, 0x100003DBu, + 0x100003DCu, 0x100003DDu, 0x100003DEu, 0x100003DFu, + ]; + foreach (uint buttonId in templateButtonIds) + { + Assert.IsType( + UiElement.FindDescendant(professionRoot, buttonId)); + } + + uint[] sliderContainerIds = + [ + 0x100003E6u, 0x100003E7u, 0x100003E8u, + 0x100003E9u, 0x100003EAu, 0x100003EBu, + ]; + foreach (uint containerId in sliderContainerIds) + { + UiElement container = Assert.IsAssignableFrom( + UiElement.FindDescendant(professionRoot, containerId)); + Assert.IsType( + UiElement.FindDescendant(container, 0x100002EEu)); + // The value display authors as an editable Type-12 (retail's + // NumberInputFilter) — DatWidgetFactory maps that to UiField, + // not UiText. See CharacterCreationProfessionPage's ctor comment. + Assert.IsType( + UiElement.FindDescendant(container, 0x100002EFu)); + } + + // Avail/health/stamina/mana each author as a Button whose Type-12 + // value child is swallowed by UiButton.ConsumesDatChildren — the + // same substitution the Skills page's credits meter needed. See + // CharacterCreationProfessionPage's ctor comment. + foreach (uint containerId in new[] + { 0x100003E2u, 0x100003E3u, 0x100003E4u, 0x100003E5u }) + { + Assert.IsType( + UiElement.FindDescendant(professionRoot, containerId)); + } + } + + /// Skills listbox, credits meter, info panes (Skills page — + /// gmCGSkillsPage::InitializePage @ 0x00481dd0). + [InstalledDatFact] + public void SkillsPage_HasListboxCreditsAndInfoPanes() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, + CharacterCreationUiController.RootEnum, + 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement skillsRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.SkillsPageElementId)); + + Assert.IsType( + UiElement.FindDescendant(skillsRoot, 0x100003F7u)); + // The credits meter (decomp id 0x100002f3) authors as a raw dat + // child of button 0x100003f9; UiButton.ConsumesDatChildren swallows + // it before it becomes an addressable widget, so the faithful + // acdream substitute is the button's own Label — see + // CharacterCreationSkillsPage's ctor comment. + Assert.IsType( + UiElement.FindDescendant(skillsRoot, 0x100003F9u)); + Assert.IsType( + UiElement.FindDescendant(skillsRoot, 0x100003FBu)); + Assert.IsType( + UiElement.FindDescendant(skillsRoot, 0x100003FCu)); + } + + /// Four town buttons + description text (Town page — + /// gmCGTownPage::InitializePage @ 0x0047c6d0). + [InstalledDatFact] + public void TownPage_HasFourStarterAreaButtons() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, + CharacterCreationUiController.RootEnum, + 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement townRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.TownPageElementId)); + + foreach (uint buttonId in new[] { 0x1000040Bu, 0x1000040Du, 0x1000040Eu, 0x1000040Fu }) + { + Assert.IsType( + UiElement.FindDescendant(townRoot, buttonId)); + } + Assert.IsType( + UiElement.FindDescendant(townRoot, 0x10000409u)); + } + + /// The exit-warning + all per-heritage/per-town DAT string + /// keys this slice cites actually resolve in the installed table. + /// + [InstalledDatFact] + public void RequiredChargenStringsResolve() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + var strings = new DatStringResolver(dats); + const uint table = 0x23000002u; + + string[] keys = + [ + "ID_CharGen_ExitWarning", + "ID_CharGen_Heritage_StartingSkills_Header", + "ID_CharGen_Heritage_StartingSkills", + "ID_CharGen_Heritage_BonusSkills_Trained_Header", + "ID_CharGen_AluvianText_BonusSkills_Trained", + "ID_CharGen_GaruText_BonusSkills_Trained", + "ID_CharGen_ShoText_BonusSkills_Trained", + "ID_CharGen_ViaText_BonusSkills_Trained", + "ID_CharGen_ShadText_BonusSkills_Trained", + "ID_CharGen_GearText_BonusSkills_Trained", + "ID_CharGen_AunTText_BonusSkills_Trained", + "ID_CharGen_EmpText_BonusSkills_Trained", + "ID_CharGen_UndText_BonusSkills_Trained", + "ID_CharGen_TownHowTo", + "ID_CharGen_HoltText", + "ID_CharGen_ShoushiText", + "ID_CharGen_YaraqText", + "ID_CharGen_SanamarText", + ]; + foreach (string key in keys) + { + string? resolved = strings.Resolve(table, DatStringResolver.ComputeHash(key)); + Assert.True(resolved is not null, $"missing string: {key}"); + } + } + + private static RetailDialogFactory MakeDialogFactory(IDatReaderWriter dats, UiRoot host) + { + uint dialogDid = RetailDataIdResolver.Resolve(dats, 2u, 5u); + ImportedLayout? CreateLayout(RetailDialogType type) + { + uint rootElementId = RetailDialogFactory.RootElementId(type); + return rootElementId == 0u + ? null + : LayoutImporter.Import( + dats, dialogDid, rootElementId, _ => (0u, 0, 0), null); + } + return new RetailDialogFactory(host, CreateLayout); + } + + private static void AssertButton(ImportedLayout layout, uint elementId) => + Assert.IsType(layout.FindElement(elementId)); + + private static ImportedLayout BuildSelected( + IDatReaderWriter dats, + uint layoutDid, + uint rootId) + { + ElementInfo info = Assert.IsType( + LayoutImporter.ImportInfos(dats, layoutDid, rootId)); + return LayoutImporter.Build( + info, + _ => (0u, 0, 0), + null, + null, + new DatStringResolver(dats).Resolve); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs new file mode 100644 index 00000000..e8e8216d --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs @@ -0,0 +1,817 @@ +using System.Numerics; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Core.CharGen; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Campaign CC slice CC4 — controller binding tests for the character- +/// creation master shell + Heritage/Profession/Skills/Town pages, using a +/// hand-built layout fixture (no installed DAT — see +/// for the live-DAT id/type +/// sweep this pairs with). Mirrors CharacterManagementUiControllerTests' +/// fixture pattern. +/// +public sealed class CharacterCreationUiControllerTests +{ + private const uint AluvianId = 1u; + private const uint OlthoiId = (uint)ChargenHeritageGroup.Olthoi; + private const uint GenderKey = 1u; + private const uint SkillTrainOnly = 1u; + private const uint SkillSpecializable = 2u; + + [Fact] + public void ActiveScreen_KeepsAuthoredRootExtent_AndDefaultsToTheHeritagePage() + { + using var environment = new EnvironmentHarness(); + Assert.False(environment.Controller.Root.Visible); + + environment.Controller.Open(); + + Assert.True(environment.Controller.Root.Visible); + Assert.Equal(800f, environment.Controller.Root.Width); + Assert.Equal(600f, environment.Controller.Root.Height); + Assert.True(environment.Page( + CharacterCreationUiController.HeritagePageElementId).Visible); + Assert.False(environment.Page( + CharacterCreationUiController.ProfessionPageElementId).Visible); + Assert.True(environment.TabButton( + CharacterCreationUiController.HeritageTabElementId).Selected); + } + + [Fact] + public void TabClick_SwitchesToTheClickedPage_FreeOfValidation() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + + // Free navigation: jumping straight to Town from Heritage with + // nothing selected must still work (gmCharGenMainUI's tab dispatch + // is not gated — see ApplyProgressState's doc). + environment.TabButton(CharacterCreationUiController.TownTabElementId) + .OnClick!(); + + Assert.True(environment.Page( + CharacterCreationUiController.TownPageElementId).Visible); + Assert.False(environment.Page( + CharacterCreationUiController.HeritagePageElementId).Visible); + Assert.True(environment.TabButton( + CharacterCreationUiController.TownTabElementId).Selected); + } + + [Fact] + public void Next_AdvancesOnePageAtATime_AndBackReturns() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + + environment.Button(CharacterCreationUiController.NextElementId).OnClick!(); + Assert.True(environment.Page( + CharacterCreationUiController.ProfessionPageElementId).Visible); + + environment.Button(CharacterCreationUiController.BackElementId).OnClick!(); + Assert.True(environment.Page( + CharacterCreationUiController.HeritagePageElementId).Visible); + } + + [Fact] + public void Next_AtSummary_IsANoOp() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.TabButton(CharacterCreationUiController.SummaryTabElementId) + .OnClick!(); + Assert.True(environment.Page( + CharacterCreationUiController.SummaryPageElementId).Visible); + + environment.Button(CharacterCreationUiController.NextElementId).OnClick!(); + + Assert.True(environment.Page( + CharacterCreationUiController.SummaryPageElementId).Visible); + } + + [Fact] + public void Finish_StaysGhosted_NoOnClickHandler() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + UiButton finish = environment.Button(CharacterCreationUiController.FinishElementId); + Assert.Null(finish.OnClick); + Assert.False(finish.Enabled); + } + + [Fact] + public void Random_IsDisabledOnSkillsAppearanceAndSummaryPages() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + UiButton random = environment.Button(CharacterCreationUiController.RandomElementId); + Assert.True(random.Enabled); + + environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!(); + Assert.False(random.Enabled); + + environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!(); + Assert.False(random.Enabled); + + environment.TabButton(CharacterCreationUiController.SummaryTabElementId).OnClick!(); + Assert.False(random.Enabled); + + environment.TabButton(CharacterCreationUiController.TownTabElementId).OnClick!(); + Assert.True(random.Enabled); + } + + /// gmCharGenMainUI::ListenToElementMessage @ 0x004e9450's + /// element 0x100003c6 case: at Heritage (the first page), Back opens + /// the exit confirmation instead of moving pages. + [Fact] + public void Back_AtHeritage_OpensExitConfirmation() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + + environment.Button(CharacterCreationUiController.BackElementId).OnClick!(); + + Assert.True(environment.Dialogs.IsOpen); + } + + [Fact] + public void Exit_Confirm_ClosesTheScreenAndCallsRequestExit() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + + environment.Button(CharacterCreationUiController.ExitElementId).OnClick!(); + Assert.True(environment.Dialogs.IsOpen); + + environment.ConfirmActiveDialog(confirmed: true); + + Assert.False(environment.Controller.Root.Visible); + Assert.Equal(1, environment.Runtime.RequestExitCalls); + } + + [Fact] + public void Exit_Cancel_LeavesTheScreenOpen() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + + environment.Button(CharacterCreationUiController.ExitElementId).OnClick!(); + environment.ConfirmActiveDialog(confirmed: false); + + Assert.True(environment.Controller.Root.Visible); + Assert.Equal(0, environment.Runtime.RequestExitCalls); + } + + /// gmCGHeritagePage::ListenToElementMessage @ 0x00483860's + /// per-button SetHeritageGroup literal, plus CC4's interim + /// auto-gender-select seam (register AD-100). + [Fact] + public void HeritageButton_SelectsHeritage_AndAutoSelectsFirstGender() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + + environment.Button(0x100003BFu).OnClick!(); // Aluvian + + Assert.Equal(AluvianId, environment.Runtime.LastSelectedHeritage); + Assert.Equal(GenderKey, environment.Runtime.LastSelectedGender); + } + + /// gmCharGenMainUI::SetProgressState @ 0x004e7a10's Olthoi + /// branch: Profession/Skills/Town tabs hide, and paging past the + /// hidden range redirects to Appearance/Summary. + [Fact] + public void OlthoiHeritage_HidesProfessionSkillsAndTownTabs() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(OlthoiId); + + environment.TabButton(CharacterCreationUiController.HeritageTabElementId) + .OnClick!(); + + Assert.False(environment.TabButton( + CharacterCreationUiController.ProfessionTabElementId).Visible); + Assert.False(environment.TabButton( + CharacterCreationUiController.SkillsTabElementId).Visible); + Assert.False(environment.TabButton( + CharacterCreationUiController.TownTabElementId).Visible); + + // Next from Heritage would normally land on Profession; for an + // Olthoi heritage it must redirect straight to Appearance. + environment.Button(CharacterCreationUiController.NextElementId).OnClick!(); + Assert.True(environment.Page( + CharacterCreationUiController.AppearancePageElementId).Visible); + } + + [Fact] + public void ProfessionTemplateButton_SelectsTemplate() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.TabButton(CharacterCreationUiController.ProfessionTabElementId) + .OnClick!(); + + environment.Button(0x100003DAu).OnClick!(); // Bow Hunter = template index 1 + + Assert.Equal(1u, environment.Runtime.LastSelectedTemplate); + } + + [Fact] + public void ProfessionSlider_ScalarChange_SetsTheAttribute() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.TabButton(CharacterCreationUiController.ProfessionTabElementId) + .OnClick!(); + + UiElement strengthContainer = Assert.IsAssignableFrom( + environment.Screen.FindElement(0x100003E6u)); + var slider = Assert.IsType( + UiElement.FindDescendant(strengthContainer, 0x100002EEu)); + + slider.ScalarChanged!(1f); // top of the [10,100] range + + Assert.Equal(ChargenAttributeId.Strength, environment.Runtime.LastAttributeSet); + Assert.Equal(100, environment.Runtime.LastAttributeValue); + } + + [Fact] + public void ProfessionValueField_DirectEntry_SetsTheAttribute() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.TabButton(CharacterCreationUiController.ProfessionTabElementId) + .OnClick!(); + + UiElement strengthContainer = Assert.IsAssignableFrom( + environment.Screen.FindElement(0x100003E6u)); + var field = Assert.IsType( + UiElement.FindDescendant(strengthContainer, 0x100002EFu)); + + field.OnSubmit!("42"); + + Assert.Equal(ChargenAttributeId.Strength, environment.Runtime.LastAttributeSet); + Assert.Equal(42, environment.Runtime.LastAttributeValue); + } + + [Fact] + public void SkillsRow_Click_TrainsThenSpecializes() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.TabButton(CharacterCreationUiController.SkillsTabElementId) + .OnClick!(); + + // Rows are built in ascending skill-id order (RebuildRows' 1..54 + // walk over IsCostable ids) — SkillSpecializable's row is identified + // by its label prefix (FormatSkillLabel's "{name}: ..." shape) + // rather than instance identity, since the controller owns the + // row->skillId map privately. + string skillName = ItemAppraisalTextFormatter.SkillName((int)SkillSpecializable); + UiButton row = environment.SkillsList().ViewportForTest!.Children + .OfType() + .Single(candidate => candidate.Label!.StartsWith( + skillName + ":", StringComparison.Ordinal)); + + row.OnClick!(); + Assert.Equal(ChargenSkillAdvancementClass.Trained, + environment.Runtime.GetSkillLevel(SkillSpecializable)); + + row.OnClick!(); + Assert.Equal(ChargenSkillAdvancementClass.Specialized, + environment.Runtime.GetSkillLevel(SkillSpecializable)); + + row.OnDoubleClick!(); + Assert.Equal(ChargenSkillAdvancementClass.Trained, + environment.Runtime.GetSkillLevel(SkillSpecializable)); + } + + [Fact] + public void TownButton_SelectsTheLiteralStartAreaIndex() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.TabButton(CharacterCreationUiController.TownTabElementId) + .OnClick!(); + + // Holtburg (0x1000040d) -> startArea 0 per SetTown's literal map. + environment.Button(0x1000040Du).OnClick!(); + Assert.Equal(0, environment.Runtime.LastSelectedStartArea); + + // Yaraq (0x1000040e) -> startArea 2. + environment.Button(0x1000040Eu).OnClick!(); + Assert.Equal(2, environment.Runtime.LastSelectedStartArea); + } + + private static IEnumerable Descendants(UiElement root) + { + yield return root; + foreach (UiElement child in root.Children) + foreach (UiElement descendant in Descendants(child)) + yield return descendant; + } + + // ── Fixture ────────────────────────────────────────────────────────── + + private sealed class EnvironmentHarness : IDisposable + { + private readonly List _dialogLayouts = []; + + public EnvironmentHarness() + { + Host = new UiRoot { Width = 800f, Height = 600f }; + Screen = BuildScreen(); + Runtime = new FakeRuntime(); + Dialogs = new RetailDialogFactory(Host, type => + { + ImportedLayout layout = RetailDialogFactoryTests.BuildDialogLayout(type); + _dialogLayouts.Add(layout); + return layout; + }); + Controller = Assert.IsType( + CharacterCreationUiController.CreateDetached( + Host, + Screen, + ResolveSkillRowTemplate, + Dialogs, + Runtime.Bindings, + new CharacterCreationUiController.DialogStrings( + "Are you sure you want to leave?"))); + Controller.AttachAndTick(); + } + + public UiRoot Host { get; } + public ImportedLayout Screen { get; } + public FakeRuntime Runtime { get; } + public RetailDialogFactory Dialogs { get; } + public CharacterCreationUiController Controller { get; } + + public UiButton Button(uint id) => + Assert.IsType(Screen.FindElement(id)); + + public UiButton TabButton(uint id) => Button(id); + + public UiElement Page(uint id) => + Assert.IsAssignableFrom(Screen.FindElement(id)); + + public UiTemplateListBox SkillsList() => + Assert.IsType(Screen.FindElement(0x100003F7u)); + + /// Confirms or cancels the MOST RECENTLY opened confirmation + /// dialog, using 's real + /// button ids off the layout the factory's createLayout + /// callback actually returned — same lookup shape + /// CharacterManagementUiControllerTests uses. + public void ConfirmActiveDialog(bool confirmed) + { + ImportedLayout dialog = _dialogLayouts[^1]; + uint buttonId = confirmed + ? RetailConfirmationDialogView.AcceptButtonId + : RetailConfirmationDialogView.RejectButtonId; + UiButton button = Assert.IsType(dialog.FindElement(buttonId)); + button.OnClick!(); + } + + private static UiElement? ResolveSkillRowTemplate( + uint templateLayoutId, + uint templateElementId) => + BuildSkillRowTemplate(templateElementId); + + public void Dispose() + { + Controller.Dispose(); + Dialogs.Dispose(); + } + } + + private sealed class FakeRuntime + { + private static readonly RuntimeGenerationToken Generation = new(3u); + + public FakeRuntime() + { + View = new FakeView(BuildOptions()); + Bindings = new CharacterCreationRuntimeBindings( + () => ProvideView ? View : null, + SelectHeritage, + SelectGender, + SelectTemplate, + SetAttribute, + (_, _) => Result(RuntimeCommandStatus.Accepted), + skillId => SetSkillLevel(skillId, ChargenSkillAdvancementClass.Trained), + skillId => SetSkillLevel(skillId, ChargenSkillAdvancementClass.Specialized), + skillId => SetSkillLevel(skillId, ChargenSkillAdvancementClass.Untrained), + SelectStartArea, + _ => Result(RuntimeCommandStatus.Accepted), + () => RequestExitCalls++, + ResolveText: _ => null, + OpenOnStart: false); + } + + public FakeView View { get; } + public CharacterCreationRuntimeBindings Bindings { get; } + public bool ProvideView { get; set; } = true; + public int RequestExitCalls { get; private set; } + public uint LastSelectedHeritage { get; private set; } + public uint LastSelectedGender { get; private set; } + public uint LastSelectedTemplate { get; private set; } + public ChargenAttributeId LastAttributeSet { get; private set; } + public int LastAttributeValue { get; private set; } + public int LastSelectedStartArea { get; private set; } = -1; + + public void SelectHeritageDirect(uint heritageId) => SelectHeritage(heritageId); + + public ChargenSkillAdvancementClass GetSkillLevel(uint skillId) => + View.GetSkillLevel(skillId); + + private RuntimeCommandResult SelectHeritage(uint heritageId) + { + LastSelectedHeritage = heritageId; + View.Snapshot = View.Snapshot with { HeritageId = heritageId }; + return Result(RuntimeCommandStatus.Accepted); + } + + private RuntimeCommandResult SelectGender(uint genderKey) + { + LastSelectedGender = genderKey; + View.Snapshot = View.Snapshot with { GenderKey = genderKey }; + return Result(RuntimeCommandStatus.Accepted); + } + + private RuntimeCommandResult SelectTemplate(uint templateIndex) + { + LastSelectedTemplate = templateIndex; + View.Snapshot = View.Snapshot with { Template = templateIndex }; + return Result(RuntimeCommandStatus.Accepted); + } + + private RuntimeCommandResult SetAttribute(ChargenAttributeId attribute, int value) + { + LastAttributeSet = attribute; + LastAttributeValue = value; + return Result(RuntimeCommandStatus.Accepted); + } + + private RuntimeCommandResult SetSkillLevel( + uint skillId, + ChargenSkillAdvancementClass targetClass) + { + View.SetSkillLevel(skillId, targetClass); + return Result(RuntimeCommandStatus.Accepted); + } + + private RuntimeCommandResult SelectStartArea(int startAreaIndex) + { + LastSelectedStartArea = startAreaIndex; + View.Snapshot = View.Snapshot with { StartArea = startAreaIndex }; + return Result(RuntimeCommandStatus.Accepted); + } + + private static RuntimeCommandResult Result(RuntimeCommandStatus status) => + new(status, Generation); + + private static ChargenOptions BuildOptions() + { + var gender = new ChargenGenderOptions( + GenderKey: (int)GenderKey, + Name: "Male", + Scale: 1u, + SetupId: 0x2000054u, + SoundTableId: 0u, + IconId: 0u, + BasePaletteId: 0u, + SkinPalSetId: 0u, + PhysicsTableId: 0u, + MotionTableId: 0u, + CombatTableId: 0u, + BaseObjDesc: ChargenObjDesc.Empty, + HairColors: [], + HairStyles: [], + EyeColors: [], + EyeStrips: [], + NoseStrips: [], + MouthStrips: [], + Headgears: [], + Shirts: [], + Pants: [], + Footwear: [], + ClothingColors: []); + + var templates = new List + { + new( + "Custom", + IconId: 0u, + TitleStringId: 0u, + Attributes: new ChargenAttributeValues(10, 10, 10, 10, 10, 10), + NormalSkills: [], + PrimarySkills: []), + new( + "Bow Hunter", + IconId: 0u, + TitleStringId: 0u, + Attributes: new ChargenAttributeValues(16, 10, 10, 10, 10, 10), + NormalSkills: [SkillTrainOnly], + PrimarySkills: []), + }; + + var skillCosts = new Dictionary + { + [SkillTrainOnly] = new(SkillTrainOnly, NormalCost: 2, PrimaryCost: 6), + [SkillSpecializable] = new(SkillSpecializable, NormalCost: 2, PrimaryCost: 6), + }; + + var aluvian = new ChargenHeritageOptions( + AluvianId, + "Aluvian", + IconId: 0u, + SetupId: 0x2000054u, + EnvironmentSetupId: 0u, + AttributeCredits: 66u, + SkillCredits: 50u, + PrimaryStartAreaIndices: [0, 1], + SecondaryStartAreaIndices: [], + SkillCostsBySkillId: skillCosts, + Templates: templates, + GendersByKey: new Dictionary { [(int)GenderKey] = gender }); + + var olthoi = new ChargenHeritageOptions( + OlthoiId, + "Olthoi", + IconId: 0u, + SetupId: 0x2000054u, + EnvironmentSetupId: 0u, + AttributeCredits: 60u, + SkillCredits: 0u, + PrimaryStartAreaIndices: [0], + SecondaryStartAreaIndices: [], + SkillCostsBySkillId: new Dictionary(), + Templates: + [ + new ChargenTemplate( + "Custom", + IconId: 0u, + TitleStringId: 0u, + Attributes: new ChargenAttributeValues(10, 10, 10, 10, 10, 10), + NormalSkills: [], + PrimarySkills: []), + ], + GendersByKey: new Dictionary { [(int)GenderKey] = gender }); + + var starterAreas = new List + { + new(0, "Holtburg", [new ChargenPosition(1u, Vector3.Zero, Quaternion.Identity)]), + new(1, "Shoushi", [new ChargenPosition(2u, Vector3.Zero, Quaternion.Identity)]), + new(2, "Yaraq", [new ChargenPosition(3u, Vector3.Zero, Quaternion.Identity)]), + new(3, "Sanamar", [new ChargenPosition(4u, Vector3.Zero, Quaternion.Identity)]), + }; + + return new ChargenOptions( + starterAreas, + new Dictionary + { + [AluvianId] = aluvian, + [OlthoiId] = olthoi, + }, + new Dictionary()); + } + } + + private sealed class FakeView(ChargenOptions options) : IRuntimeCharacterCreationView + { + private readonly Dictionary _skillLevels = []; + + public RuntimeCharacterCreationSnapshot Snapshot { get; set; } = + new( + new RuntimeGenerationToken(3u), + IsActive: true, + Revision: 1, + HeritageId: 0u, + GenderKey: 0u, + Appearance: RuntimeCharacterCreationAppearance.Default, + Template: RuntimeCharacterCreationSnapshot.TemplateUnset, + Attributes: default, + AttributeLockMask: 0u, + TotalAttributeCredits: 66u, + RemainingAttributeCredits: 66, + TotalSkillCredits: 50u, + RemainingSkillCredits: 50, + Name: string.Empty, + StartArea: -1, + Slot: 0u, + VerificationPending: false, + LastLocalRefusal: default, + LastRejection: null, + LastCreated: null); + + public ChargenOptions Options { get; } = options; + + public ChargenSkillAdvancementClass GetSkillLevel(uint skillId) => + _skillLevels.TryGetValue(skillId, out ChargenSkillAdvancementClass level) + ? level + : ChargenSkillAdvancementClass.Inactive; + + public void SetSkillLevel(uint skillId, ChargenSkillAdvancementClass level) => + _skillLevels[skillId] = level; + + public IDisposable Subscribe(IRuntimeCharacterCreationObserver observer) => + NullSubscription.Instance; + + private sealed class NullSubscription : IDisposable + { + public static readonly NullSubscription Instance = new(); + public void Dispose() { } + } + } + + private static ImportedLayout BuildScreen() + { + var root = new ElementInfo + { + Id = CharacterCreationUiController.RootElementId, + Type = 3u, + Width = 800f, + Height = 600f, + }; + + root.Children.Add(ContainerInfo(CharacterCreationUiController.ProgressBarElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.BackElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.NextElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.FinishElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.HelpElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.ExitElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.RandomElementId)); + root.Children.Add(ContainerInfo(CharacterCreationUiController.MasterPageElementId)); + + root.Children.Add(BuildHeritagePage()); + root.Children.Add(BuildProfessionPage()); + root.Children.Add(BuildSkillsPage()); + root.Children.Add(ContainerInfo(CharacterCreationUiController.AppearancePageElementId)); + root.Children.Add(BuildTownPage()); + root.Children.Add(ContainerInfo(CharacterCreationUiController.SummaryPageElementId)); + + root.Children.Add(ButtonInfo(CharacterCreationUiController.HeritageTabElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.ProfessionTabElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.SkillsTabElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.AppearanceTabElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.TownTabElementId)); + root.Children.Add(ButtonInfo(CharacterCreationUiController.SummaryTabElementId)); + + return LayoutImporter.Build(root, _ => (0u, 0, 0), null); + } + + private static ElementInfo BuildHeritagePage() + { + var page = new ElementInfo + { + Id = CharacterCreationUiController.HeritagePageElementId, + Type = 3u, + Width = 800f, + Height = 500f, + }; + page.Children.Add(ButtonInfo(0x100003BFu)); // Aluvian + page.Children.Add(ButtonInfo(0x100005C7u)); // Olthoi + page.Children.Add(TextInfo(0x100003C4u)); + return page; + } + + private static ElementInfo BuildProfessionPage() + { + var page = new ElementInfo + { + Id = CharacterCreationUiController.ProfessionPageElementId, + Type = 3u, + Width = 800f, + Height = 500f, + }; + page.Children.Add(ButtonInfo(0x100003D9u)); // Custom + page.Children.Add(ButtonInfo(0x100003DAu)); // Bow Hunter + + var strengthSlider = ContainerInfo(0x100003E6u); + strengthSlider.Children.Add(ButtonInfo(0x100002ECu)); + strengthSlider.Children.Add(ScrollbarInfo(0x100002EEu)); + strengthSlider.Children.Add(EditableFieldInfo(0x100002EFu)); + page.Children.Add(strengthSlider); + + page.Children.Add(ButtonInfo(0x100003E2u)); // Available (consumed-child badge) + page.Children.Add(ButtonInfo(0x100003E3u)); // Health + page.Children.Add(ButtonInfo(0x100003E4u)); // Stamina + page.Children.Add(ButtonInfo(0x100003E5u)); // Mana + return page; + } + + private static ElementInfo BuildSkillsPage() + { + var page = new ElementInfo + { + Id = CharacterCreationUiController.SkillsPageElementId, + Type = 3u, + Width = 800f, + Height = 500f, + }; + var list = new ElementInfo + { + Id = 0x100003F7u, + Type = 5u, + X = 20f, + Y = 40f, + Width = 300f, + Height = 320f, + }; + list.TemplateList.Add(new UiTemplateListEntry(0x21000038u, 0x100003FEu)); + page.Children.Add(list); + page.Children.Add(ButtonInfo(0x100003F9u)); // credits badge + page.Children.Add(TextInfo(0x100003FBu)); + page.Children.Add(TextInfo(0x100003FCu)); + return page; + } + + private static ElementInfo BuildTownPage() + { + var page = new ElementInfo + { + Id = CharacterCreationUiController.TownPageElementId, + Type = 3u, + Width = 800f, + Height = 500f, + }; + page.Children.Add(ButtonInfo(0x1000040Bu)); // Sanamar + page.Children.Add(ButtonInfo(0x1000040Du)); // Holtburg + page.Children.Add(ButtonInfo(0x1000040Eu)); // Yaraq + page.Children.Add(ButtonInfo(0x1000040Fu)); // Shoushi + page.Children.Add(TextInfo(0x10000409u)); + return page; + } + + private static UiElement BuildSkillRowTemplate(uint templateElementId) => + LayoutImporter.Build( + new ElementInfo + { + Id = templateElementId, + Type = 1u, + Width = 280f, + Height = 16f, + }, + _ => (0u, 0, 0), + null).Root; + + private static ElementInfo ContainerInfo(uint id) => new() + { + Id = id, + Type = 3u, + Width = 200f, + Height = 60f, + }; + + private static ElementInfo ButtonInfo(uint id) => new() + { + Id = id, + Type = 1u, + Width = 100f, + Height = 30f, + }; + + private static ElementInfo TextInfo(uint id) => new() + { + Id = id, + Type = 12u, + Width = 200f, + Height = 60f, + }; + + private static ElementInfo ScrollbarInfo(uint id) => new() + { + Id = id, + Type = 11u, + Width = 120f, + Height = 12f, + }; + + private static ElementInfo EditableFieldInfo(uint id) + { + var info = new ElementInfo + { + Id = id, + Type = 12u, + Width = 40f, + Height = 16f, + }; + var state = new UiStateInfo { Id = UiStateInfo.DirectStateId }; + state.Properties.Values[0x16u] = new UiPropertyValue + { + Kind = UiPropertyKind.Bool, + BoolValue = true, + }; + info.States[UiStateInfo.DirectStateId] = state; + return info; + } +} diff --git a/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs b/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs index d3f93985..175364ea 100644 --- a/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs +++ b/tests/AcDream.Runtime.Tests/CharGen/RuntimeCharacterCreationStateTests.cs @@ -57,6 +57,54 @@ public sealed class RuntimeCharacterCreationStateTests Assert.False(state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId)); } + // ── Options threading (Campaign CC slice CC4) ─────────────────────── + // RuntimeCharacterCreationState.InstallOptions — the App-startup seam + // ContentEffectsAudioCompositionPhase.Compose calls once portal.dat's + // ChargenTableReader.Load result is available, mirroring + // RuntimeCharacterState.InstallSpellMetadata's "install immutable DAT + // metadata after construction" pattern. + + [Fact] + public void InstallOptions_BeforeBegin_ReplacesTheOptionsLaterCommandsUse() + { + var state = new RuntimeCharacterCreationState(ChargenOptions.Empty); + + state.InstallOptions(RuntimeCharacterCreationStateFixture.Build()); + state.Begin(new RuntimeGenerationToken(1)); + + Assert.True(state.TrySelectHeritage(RuntimeCharacterCreationStateFixture.AluvianId)); + Assert.Equal( + RuntimeCharacterCreationStateFixture.AluvianId, + state.Snapshot.HeritageId); + } + + [Fact] + public void InstallOptions_WhileActive_ThrowsInsteadOfRacingLiveCommands() + { + RuntimeCharacterCreationState state = CreateActive(); + + Assert.Throws( + () => state.InstallOptions(RuntimeCharacterCreationStateFixture.Build())); + } + + [Fact] + public void InstallOptions_NullOptions_Throws() + { + var state = new RuntimeCharacterCreationState(ChargenOptions.Empty); + + Assert.Throws(() => state.InstallOptions(null!)); + } + + [Fact] + public void InstallOptions_AfterDispose_Throws() + { + var state = new RuntimeCharacterCreationState(ChargenOptions.Empty); + state.Dispose(); + + Assert.Throws( + () => state.InstallOptions(RuntimeCharacterCreationStateFixture.Build())); + } + // ── Heritage / gender / template ──────────────────────────────────── [Fact] diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs index 48064bc3..7acf96e5 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionLifecycleHostTests.cs @@ -40,6 +40,61 @@ public sealed class LiveSessionLifecycleHostTests host.DetachSession(sessionB); } + /// Campaign CC slice CC4: / + /// forward to + /// the bindings' new optional delegates. + [Fact] + public void CharacterCreatedAndCreationFailed_ForwardToTheOptionalBindings() + { + var calls = new List(); + var host = new LiveSessionLifecycleHost(new LiveSessionLifecycleBindings( + Bind: session => CreateBinding(session, calls), + Reset: _ => { }, + Connecting: (_, _, _) => { }, + Connected: () => { }, + Roster: _ => { }, + Selected: _ => { }, + Entered: _ => { }, + CharacterCreated: identity => calls.Add($"created:{identity.Guid:X8}:{identity.Name}"), + CreationFailed: rejection => calls.Add($"failed:{rejection.Reason}"))); + + host.ApplyCharacterCreated(new RuntimeCharacterCreationIdentity(0x50000001u, "Toon")); + host.ApplyCreationFailed(new RuntimeCharacterCreationRejection( + 3u, + AcDream.Core.Net.Messages.CharGenVerificationResponse.Code.NameInUse, + "NameInUse", + "Toon")); + + Assert.Equal(["created:50000001:Toon", "failed:NameInUse"], calls); + } + + /// The two delegates default to — every + /// construction site that predates CC4 keeps compiling and behaves as a + /// no-op, matching 's + /// own default-interface no-op. + [Fact] + public void CharacterCreatedAndCreationFailed_DefaultToNoOp_WhenBindingsOmitThem() + { + var calls = new List(); + var host = new LiveSessionLifecycleHost(new LiveSessionLifecycleBindings( + Bind: session => CreateBinding(session, calls), + Reset: _ => { }, + Connecting: (_, _, _) => { }, + Connected: () => { }, + Roster: _ => { }, + Selected: _ => { }, + Entered: _ => { })); + + host.ApplyCharacterCreated(new RuntimeCharacterCreationIdentity(1u, "Toon")); + host.ApplyCreationFailed(new RuntimeCharacterCreationRejection( + 3u, + AcDream.Core.Net.Messages.CharGenVerificationResponse.Code.NameInUse, + "NameInUse", + "Toon")); + + Assert.Empty(calls); + } + [Fact] public void FailedBindingFactoryDoesNotClaimTheHost() {