diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index acfa8ed6..20985013 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) — 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) +## 2. Adaptation (AD) — 78 active rows (AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -195,12 +195,11 @@ readiness/requeue adaptation. See | 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. **Review fix round F5 (2026-08-15): the composited pixel result is EXPECTED unchanged (same number, same badge) but NOT measured** — `UiButton.ConsumesDatChildren` discards the child's authored rect/font/justify entirely rather than rebuilding at the child's dat-local coordinates the way `UiMeter`'s carve-out does, and `CharacterCreationLiveDatTests` asserts only widget TYPE (button vs. the swallowed Type-12), not the rendered rect/font/justify of the substituted `.Label` against what the discarded child would have drawn. Treat the equivalence claim as unverified until a probe compares them. | 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`; separately, closing the pixel-equivalence gap above needs either a rect/justify comparison probe or a `UiMeter`-style carve-out. | `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. **Review fix round F9 (2026-08-15) — retirement sequencing correction: this row MUST retire before CC5's Finish un-ghosts, not merely "at CC6b."** CC5 (Summary page + the real Finish gate) lands before CC6b in the campaign's own slice order; if Finish un-ghosts while this row is still live, a create can complete end-to-end on an IMPLICIT gender default the player never chose or saw — CC6b's explicit gender buttons must land no later than CC5's Finish wiring, or CC5 must itself surface the implicit choice, whichever the campaign plan schedules first. Until retired, 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; worse, if CC5 ships Finish before this row retires, a real character can be CREATED with a gender the player never picked. | `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) — 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) +## 3. Documented approximation (AP) — 151 active rows (AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -390,6 +389,8 @@ 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-215 | **Filed 2026-08-15 at Campaign CC slice CC6b-MOUNT (Appearance page visual substitutions).** Two narrow, DECIDED substitutions where acdream reaches the same functional selection through a different widget mechanism than retail's own: (1) the nine color swatches (`0x1000030f-0x10000317`) use their own `UiButton.Selected` highlight state for "this is the current color" instead of toggling the separate Type-3 companion overlay element (`0x10000318-0x10000320`) retail's `SetColor @ 0x0047DD50` shows/hides via `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible` — the composited pixel result is UNVERIFIED to match, not asserted identical (same "measured, not assumed" discipline AD-103's own F5 note established for a different swallowed-child case). (2) the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name string) show a 1-based ordinal number instead of retail's actual icon thumbnail; the four clothing spins (headgear/shirt/trousers/footwear) DO show a real name since `ChargenGearOption.Name` exists. Icon rendering for chargen's own preview icons is out of this round's scope entirely (no icon-texture pipeline is wired to ANY chargen widget yet). | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s swatch loop; `SetStyleSpinLabel`) | Both substitutions reach the SAME underlying selection (the swatch highlight still shows which color index is active; the ordinal still lets a player cycle deterministically and see which slot they're on) through existing widget primitives (`UiButton.Selected`, `UiButton.Label`) rather than adding new rendering infrastructure (a second overlay-visibility channel, or an icon-texture pipeline) this slice's scope doesn't otherwise need. | A pixel-level side-by-side against retail would show a different (simpler) selected-swatch visual and text labels where retail shows icon art — a cosmetic gap only; no selection state, index, or wire value differs. A future icon-rendering pass (if chargen ever needs one, e.g. for the heritage/template icons too) would naturally close the label half of this row. | `gmCGAppearancePage::SetColor @0x0047DD50` (the `m_tColorWheel` overlay toggle); `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip`/`ChargenGearOption` (CC1, `src/AcDream.Core/CharGen/ChargenAppearanceOptions.cs`) | +| AP-214 | **Filed 2026-08-15 at Campaign CC slice CC6b-MOUNT (AD-101's retirement research).** Retail's chargen screen does NOT open blank: `gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0` calls `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80` (~`0x004e81f5`-`0x004e8218`) BEFORE constructing any page (Heritage/Profession/Skills/Appearance/Town/Summary all `InitializePage` AFTER this call) — `RandomizeCharacter` itself Resets then rolls a random heritage (`RollDice(1, hasToD?4:3)`), a random gender (`RollDice(1,2)`), `RandomizeAppearance`, `RandomizeHeadgear`/`Shirt`/`Trousers`/`Footwear`, `RandomizeTemplate`, and `RandomizeStartArea`, freezing heritage/sex/appearance. This ALSO resolves the plan's risk item 5 "gender-flip-on-init oddity" at `gmCGAppearancePage::InitializePage @0x0047FDD0` (~`0x004802DA`-`0x00480303`): since `RandomizeCharacter` already assigned a real (non-zero) gender before the Appearance page constructs, that page's own gender-read-and-FLIP-to-the-opposite code ALWAYS fires on first open, deterministically inverting `RandomizeCharacter`'s random gender pick — a genuine, always-reachable retail quirk, not a latent/unreachable one. acdream does not port `RandomizeCharacter` this round — the same six missing Runtime primitives (`RandomizeHeritageGroup`/`RandomizeGender`-via-`SetGender`/`RandomizeAppearance`/`RandomizeClothing`(via the four Randomize* gear calls)/`RandomizeTemplate`/`RandomizeStartArea`) AP-212 already tracks for the Random BUTTON are the SAME gap that would be needed here — so acdream's chargen screen opens honestly blank (heritage/gender/appearance all `Unset`) and the player makes every choice explicitly, including gender on the Appearance page (AD-101's retirement). | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (no `RandomizeCharacter`-equivalent call at construction — the gap itself); `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`Select`, AD-101's retirement point) | Full-fidelity would require porting `RandomizeCharacter` and its six sub-primitives into Runtime (AP-212's own "known landing site" note) — out of this slice's scope, which is the Appearance page's own controls, not a fourth cut at the Random button's primitives. Landing this WOULD ALSO close AP-212's gap for the "Random button while on Summary" case, since retail's `DoRandom`'s own Summary branch is a direct `RandomizeCharacter` call. | A connected two-client visual gate comparing "what does the chargen preview show on first open" against retail would see a blank/default acdream character versus retail's fully-randomized one — an expected, documented divergence, not a bug; the FLIP quirk itself has zero acdream analogue to diverge from (there's nothing to flip when gender starts Unset). | `gmCharGenMainUI::gmCharGenMainUI @0x004e7eb0` (`~0x004e81f5-0x004e8218`); `CharGenState::RandomizeCharacter @0x005c6d80`; `CharGenState::Reset @0x005c68a0` (confirms `SetGender(this,0)` is the ONLY other gender-touching call in the reset path); `gmCGAppearancePage::InitializePage @0x0047FDD0` (`~0x004802da-0x00480303`, the gender-flip arm) | | 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`); primitives named+cited in the review fix round (F8, 2026-08-15).** `gmCharGenMainUI::DoRandom @ 0x004e7d70` switches on the current page and dispatches to six NAMED, fully decompiled retail primitives, one per page: Heritage -> `CharGenState::RandomizeHeritageGroup(state, hasToD) @ 0x005c6a20` (called with `CPlayerSystem::AccountHasThroneOfDestiny`); Profession -> `CharGenState::RandomizeTemplate(state) @ 0x005c6500`; Skills -> `CharGenState::RandomizeSkills(state) @ 0x005c57e0`; Appearance -> `CharGenState::RandomizeAppearance(state, 0) @ 0x005c4f10` or `CharGenState::RandomizeClothing(state, 1) @ 0x005c6770` depending on the page's current sub-choice (`m_eCurType == ECG_CHOICE_CLOTHES`); Town -> `CharGenState::SetStartArea(state, RandInt(hasToD ? 4 : 3))`; Summary -> `CharGenState::RandomizeCharacter(state, hasToD) @ 0x005c6d80`. None of these six is exposed as a CC3 Runtime command primitive today. 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, Appearance (this round's placeholder), and Summary (this round's placeholder — no `CharacterCreationSummaryPage` exists yet to host a randomize-warning dialog; see TS-82). | `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 six more retail algorithms this slice did not scope. This is DEFERRED work with a known landing site, not an unrecoverable gap: all six primitives are named and decompiled above, and the natural home for a faithful port is Runtime, beside CC3's other `CharGenState` ports (`RuntimeCharacterCreationState`), exposed as new commands the App-layer `Randomize` methods on each page would call instead of picking uniformly. | 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/Appearance/Summary have no Random affordance at all until their respective primitives/pages land. | `gmCharGenMainUI::DoRandom @ 0x004e7d70`; `CharGenState::RandomizeHeritageGroup @ 0x005c6a20`; `CharGenState::RandomizeTemplate @ 0x005c6500`; `CharGenState::RandomizeSkills @ 0x005c57e0`; `CharGenState::RandomizeAppearance @ 0x005c4f10`; `CharGenState::RandomizeClothing @ 0x005c6770`; `CharGenState::RandomizeCharacter @ 0x005c6d80`; `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) | @@ -406,7 +407,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 (**review fix round F11 (2026-08-15) — corrected cross-reference: this row's OWN CC5 dependency, not AP-211**, which is an unrelated roster-slot-cap local refusal — `CharacterCreationUiController`'s `_finish.OnClick = null` ctor comment names this row directly as the reason Finish has no handler this slice) so no create can complete through this screen until CC5 wires the Summary page's name field and the real Finish gate. | `gmCGAppearancePage @ 0x0047de70`; `gmCGSummaryPage` (InitializePage @ 136566 per the campaign plan); `docs/plans/2026-08-15-character-creation-campaign.md` (Slices CC5/CC6a/CC6b) | +| TS-82 | **Filed 2026-08-15 at Campaign CC slice CC4. NARROWED to Summary-only 2026-08-15 at Campaign CC slice CC6b-MOUNT.** The Summary (`0x100003d6`, `gmCGSummaryPage`) page root mounts as an EMPTY, content-inert placeholder — visible/reachable through the master shell's free tab navigation (a player can click the Summary tab and land on a blank page) but with none of retail's own controls built: no name field, no summary listbox, no static preview. Explicitly scoped to CC5 (Summary + the Finish gate's real UI). **The Appearance page (`0x100003d4`, `gmCGAppearancePage`) is CLOSED OUT OF THIS ROW as of CC6b-MOUNT** — it now has real gender/Face-Clothes/spin/color-swatch/shade/zoom/rotate controls and a live 3D preview (`CharacterCreationAppearancePage`), so it is no longer content-inert. The master shell already ports retail's OWN visibility/state-toggle/tab-selection mechanics for the Summary page faithfully — only its CONTENT is stopgapped. | `src/AcDream.App/UI/Layout/CharacterCreationUiController.cs` (`_summaryPageRoot`, mounted but no page controller attached) | The explicitly sequenced follow-on slice CC5 owns Summary's content; building it here would duplicate work already scoped to that slice and risk drifting from its own DAT/decomp research (the name-input filter, the summary listbox, the static preview). | A player reaching Summary via free tab navigation sees an empty page instead of retail's controls; Finish stays ghosted (**review fix round F11 (2026-08-15) — corrected cross-reference: this row's OWN CC5 dependency, not AP-211**, which is an unrelated roster-slot-cap local refusal — `CharacterCreationUiController`'s `_finish.OnClick = null` ctor comment names this row directly as the reason Finish has no handler this slice) so no create can complete through this screen until CC5 wires the Summary page's name field and the real Finish gate. | `gmCGSummaryPage` (InitializePage @ 136566 per the campaign plan); `docs/plans/2026-08-15-character-creation-campaign.md` (Slice CC5) | | 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 826eb0ce..5352207f 100644 --- a/docs/plans/2026-08-15-character-creation-campaign.md +++ b/docs/plans/2026-08-15-character-creation-campaign.md @@ -258,3 +258,4 @@ the user gate. **Review fix round (F1-F12, same session):** F1 (BLOCKING) — `hairStyle.AlternateSetup != 0` / `setupId == 0` tested the wrong sentinel; retail's Setup "unset" is `INVALID_DID` (0xFFFFFFFF — `CharGenState::GetSetupID @0x005C5B22`), not 0, so an `AlternateSetup` field storing that value would have been ADOPTED as a literal Setup id, nulling `Get` and killing the whole preview. Fixed at both sites (`ChargenAppearanceFactory.cs`, new `InvalidDid` constant); two new hand-built tests plus a new installed-DAT sweep (`EveryHairStyleOfEveryHeritageGender_ComposesToARealInstalledSetupId`, 869 selections across all 26 heritage/gender combinations, zero unresolved). F2 (BLOCKING) — TS-84's register row, `ChargenClothingTable.cs`'s doc comment, and this ledger row all understated Undead's measured gap as "headgear/trousers/footwear" (3 slots) with a self-contradicting "4 of 4 non-shirt slots" aside; corrected everywhere to the true measured ALL FOUR slots (headgear, trousers, shirt, footwear). F3 (BLOCKING) — the "three independent sources" palette-math claim overcounted; corrected to the two that actually hold (decomp control flow + ACE's cited port) in `ChargenPalSetMath.cs`'s doc and this row (see above). F4 (MEDIUM, landed despite no CC6a call site yet) — `ChargenPreviewEntityBuilder.TryBuild` did unlocked dat reads; `DatCollection` is not thread-safe and every sibling dat-touching resolver in this layer takes a shared `object datLock`. Added a required `datLock` parameter; every dat read (Setup fetch, held-pose resolution, per-part GfxObj checks, surface-override resolution) now happens inside one `lock`, mirroring `RetailPaperdollPoseApplicator.Apply`'s "resolve under lock, process after" shape. F5 (LOW) — `Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate` is a PRE-EXISTING timing flake unrelated to any chargen code (passes 15/15 in isolation per the reviewer); noted here so a future session doesn't chase it as a CC6a regression. F6 (LOW) — `ChargenPreviewCamera.cs`'s rotation doc cited a nonexistent `RotationDegreesPerSecond` identifier in a dimensionally-wrong expression; corrected to retail's actual per-tick formula (`DoRotation @0x0047CAC7`: `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`). F7 (LOW-MEDIUM) — the installed-DAT tests' env-gated skip returns green with a console note when no dat dir is configured (confirmed this IS the house pattern — no Content installed-DAT test in the project uses `Assert.Skip`, so it was kept rather than diverging), but the TS-84 measurement was WriteLine-only; now pinned with real assertions (zero gaps for the 9 standard heritages, exactly the 4 measured Undead table ids on both genders — `[0x10000009, 0x100000F9, 0x10000001, 0x10000007]`, same order both genders). F8 (LOW) — the inner PalSet-miss loop recorded-and-continued past a miss; retail's own loop (`ClothingTable::BuildObjDesc` ~0x005A7B24-0x005A7BD3) returns 0 immediately on a miss at ~0x005A7B32, ABORTING every remaining choice in that garment — `continue` changed to `break`, new test proves a second (present) PalSet's choice is correctly NOT applied when it follows a missing one. F9 (LOW) — three dangling `` doc-comment references (the method is `TryCompose`) fixed. F10 (LOW) — the packed `(byte)(range.Offset/8)`/`(byte)(range.NumColors/8)` narrowing on dat-sourced data was unchecked (a real `NumColors` of 2048 wraps 256→0 as an unchecked byte cast, which HAPPENS to match retail's own "0 means whole palette" sentinel); replaced with explicit `PackOffset`/`PackNumColors` helpers that document the 2048→0 equivalence deliberately and throw `ArgumentOutOfRangeException` on any other unrepresentable shape, with two new tests (the sentinel case, the throwing case). F11/F12 (LOW, CC6b scope, no code this round) — noted in the CC6b row below: the second `m_alternateSetupID` override source (the appearance-page option checkbox — Penumbraen crown `@0x004DFB3F`, Undead no-flame `@0x004E0C54`, precedence at `@0x004EEA51`) is unmodelled; a shared `RetailHeldPose` helper is worth extracting before a fourth held-pose consumer exists (paperdoll, appraisal's live-target case is different, chargen — a third, not yet fourth). **F11 CONCEDED MIS-SCOPED at the CC6b-PRE review fix round (2026-08-15):** the two cited write sites are `gmBarberUI`'s, not `gmCGAppearancePage`'s — see the CC6b-PRE row's own corrected item 4 for the citation table (enclosing-function scan) and the resulting directive that CC6b-mount must NOT build an option checkbox here. **Test counts after the fix round (measured, not projected):** Core.Tests 4772/1 skip (+5 from F1's two hand-built tests, F8's one, F10's two), Content.Tests 147/0 skips (+1 from F1's new installed-DAT sweep — F7 added assertions to the EXISTING installed-DAT test rather than a new one), App.Tests 5121/6 skips (unchanged pass count; F5's named flake did NOT reproduce in this session's full-suite run) — zero failures, full solution Release build green. | | CC6b-PRE | PRE-MOUNT HALF CODE-COMPLETE 2026-08-15 (the mount-independent scope only — idle animation, rotation, zoom for the chargen preview; the page-mount half — Appearance page, spin controls, color wheels, viewport wiring — is a SEPARATE follow-up landing after CC4 merges, per the original CC6 split) | `8dfee111` (pre-mount half), plus a same-round review fix commit (F1-F7 + the F11-concession rewrite) | Dual-lens review returned architectural PASS with reservations + retail fidelity PASS with reservations, merge after F1 — landed this round along with F2-F7 and the ALSO item (the reviewer's claim-2 barber refutation was UPHELD; claim-1's idle-by-default CONCLUSION was correct but its "elided ctor byte" argument was unsound, replaced with the real `InitializePage` evidence) | **Idle animation loop, TS-83 RETIRED:** decomp re-read of `gmCGAppearancePage::Update`'s own trailing gate (~0x0047EF01-0x0047EF12: `if (m_bZoomedIn == 0) StartAnimation(); else StopAnimation();`, unconditional on every Update call — heritage/gender change or page becoming visible) plus the DIRECT ASSIGNMENT evidence located at the re-review — `gmCGAppearancePage::InitializePage @0x0047FDD0` writes an explicit `m_bZoomedIn = 0` at `0x004802C3`, right after setting the camera to the zoomed-IN per-heritage eye at `0x00480286-0x0048029E` (the null-tween quirk); the earlier elided-ctor-byte argument was UNSOUND (heap-new members are indeterminate, not zero) and is superseded — settles a fact CC6a's own TS-83 row left as "not yet located precisely": **retail's chargen preview defaults to the idle loop PLAYING, not the frozen rest pose** — the rest pose only appears once the user presses Zoom In, which retail's own `ZoomIn`/`ZoomOut` (`0x0047CF00`/`0x0047D050`) call `gmCG3DView::StopAnimation`/`StartAnimation` for IMMEDIATELY (before the camera's own 0.6s tween even starts). New Core primitive `RetailAnimationCyclePlayback` (`src/AcDream.Core/Physics/`, pure, unit-tested) ports `CPhysicsObj::set_sequence_animation @ 0x0050F6F0`'s effect (advance-with-wrap + lerp/slerp) — the SAME algorithm this codebase's App layer already carries inline for its no-`AnimationSequencer` NPC idle path (`LiveEntityAnimationPresenter.Present`'s legacy branch); the two call sites are NOT consolidated this round (that file is live, heavily-tested, in-flight production entity-rendering code unrelated to this preview-only feature — a deliberate blast-radius call, not an oversight, noted in the new type's own doc comment for a future mechanical pass). New App type `ChargenPreviewAnimator` (`src/AcDream.App/Rendering/`) owns the per-tick idle-frame advance / rest-pose freeze swap; `ChargenPreviewEntityBuilder` gained `TryBuildAnimated` (returns a `ChargenPreviewAnimatedBuild`: the entity, resolved drawable parts, precomputed rest pose, resolved idle Animation + frame range) alongside the ORIGINAL `TryBuild` (kept RESULT-identical, not byte-identical internally — F6: it now also resolves the idle DID and loads the idle Animation before discarding them; a thin wrapper now, all 3 of its existing tests still pass unchanged) — `ResolveIdleAnimEnum` resolves `m_didAnimation`'s enum key (0x10000006 standard, 0x10000011 Olthoi, 0x10000013 OlthoiAcid) alongside the existing `ResolveRestPoseEnum` (0x10000005/0x10000011/0x10000013) — **Olthoi and OlthoiAcid use the SAME enum key for BOTH idle and rest** (retail quirk, decomp-confirmed at ~0x004ee7e9/0x004ee7ff and ~0x004ee892/0x004ee8a8: those two heritages show no visible difference between "playing" and "zoomed in and frozen"). **Rotation controller:** new `ChargenPreviewRotationController` (`src/AcDream.App/Rendering/`) ports `gmCGAppearancePage::Rotate`/`DoRotation` (`0x0047CB50`/`0x0047CA80`) verbatim — toggle-to-stop-same-direction, `deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) * 360`, a SINGLE-PASS ±360 clamp (not a full modulo — retail's own tail only corrects once, reproduced as-is rather than "improved"), the `-1.0` sentinel `Rotate()` writes to invalidate `m_dLastRotateTime` (bit-confirmed: high dword `0xbff00000` + zero low dword). `ECG_ROTATE_CLOCKWISE=1`/`ECG_ROTATE_COUNTERCLOCKWISE=2` confirmed from `acclient.h:6848-6852` — CLOCKWISE adds to heading, everything else subtracts. Applies to the ENTITY's heading via `MoveToMath.SetHeading` (the exact existing `CPhysicsObj::set_heading` port, reused rather than reinvented), not the camera — confirming CC6a's own architecture note. **Zoom tween:** new `ChargenPreviewZoomController` ports `ZoomIn`/`ZoomOut`/`DoZoomAnimation` (`0x0047CF00`/`0x0047D050`/`0x0047C960`) — a LINEAR (not eased — the decomp shows a straight `(targ-start)*t+start` per axis with no easing curve anywhere in the function) 0.6s tween between `ChargenPreviewCamera`'s already-recorded default/zoomed-out eye profiles, using the same `-0.1` invalidation-sentinel idiom as rotation; `ZoomIn`/`ZoomOut` call into `ChargenPreviewAnimator.SetZoomedIn` IMMEDIATELY (synchronously, inside the button-press method itself — not gated on the tween's own completion), matching the decomp's call ORDER exactly. **Fix round F2:** the controller and the animator originally kept two INDEPENDENT `IsZoomedIn` bools synced only through a nullable animator argument on `ZoomIn`/`ZoomOut` — a null pass, or a direct `ChargenPreviewAnimator.SetZoomedIn` call bypassing the controller, could desync the camera target from the animation pose. Retail's `m_bZoomedIn` is a SINGLE field gating both, so `ChargenPreviewZoomController` now takes its `ChargenPreviewAnimator` as a required constructor dependency and `IsZoomedIn` reads straight through to the animator's own flag — one owner, matching retail's own shape, with no second bool left to disagree. **`m_alternateSetupID` (MUST-COVER item 1) — RESEARCH CORRECTION, not a straight port:** re-reading the decomp function-by-function (not just address-by-address) found that ALL FIVE `m_alternateSetupID` write sites — including the two the CC6a review fix round cited, Penumbraen crown `@0x004DFB3F` and Undead no-flame `@0x004E0C54` — belong to `gmBarberUI`, not `gmCGAppearancePage`. Enclosing-function table (every write site, confirmed by scanning each site's containing function body for sibling calls that only make sense in one class): `@0x004DFB5B` sits inside `gmBarberUI::ListenToElementMessage` (sibling evidence: `gmBarberUI::SetSelection`/`gmBarberUI::Rotate` calls in the same body, which ends in a `CM_Character::Event_FinishBarber` wire call — a barber-shop-only message); `@0x004E0C54` (Penumbraen crown), `@0x004E0D42`, and `@0x004E0DB1` all sit inside the SAME `gmBarberUI::InitializePage` (sibling evidence: `m_pOption1Checkbox` reads and `UIElement_Text::SetStringInfoWithFont` calls on barber-specific string ids in that body); the ONLY thing `gmCGAppearancePage` itself ever does with the field is READ it generically through the shared `gmCG3DView` ctor/`::Update` (every `gmCG3DView` owner does this) — `gmCGAppearancePage`'s own field list (`acclient.h:56373-56428`, checked exhaustively) has NO `m_pOption1Checkbox`-equivalent member and none of its own methods write `m_alternateSetupID`. `gmBarberUI` is the POST-CREATION barber-shop appearance-editing screen — a wholly separate UI class from character creation's `gmCGAppearancePage`. **For character creation, `m_alternateSetupID` is therefore ALWAYS `INVALID_DID` in retail — the barber shop's crown/flame variant checkbox is not reachable during chargen at all**, and is out of this campaign's scope entirely. **Directive for CC6b-mount: do NOT build an option checkbox for Penumbraen-crown/Undead-no-flame variants on the Appearance page — retail has no such control there.** `ChargenAppearanceFactory.TryCompose` still gained a real, decomp-cited `alternateSetupIdOverride` parameter (default `InvalidDid`, i.e. no-op for every existing caller) implementing `gmCG3DView::Update`'s own generic precedence exactly (`~0x004EEA46-0x004EEA53`: the override, when present, REPLACES the hairstyle/gender-resolved setup outright, not additively) — a real mechanism reserved for a hypothetical future non-chargen (barber-shop) consumer of this same factory, not a fabricated chargen feature; 5 new hand-built tests prove the precedence chain and the `INVALID_DID` sentinel discipline. **RetailHeldPose extraction (MUST-COVER item 2) — DONE, clean mechanical extraction:** new `src/AcDream.App/Rendering/RetailHeldPose.cs` shares `ResolvePoseDid` (master-map-slot-7 DID lookup) and `ComposePartTransform` (`Scale*Rotate*Translate`) between `RetailPaperdollPoseApplicator.Apply` (paperdoll, refactored to call the shared helper, behavior byte-identical) and `ChargenPreviewEntityBuilder` (both the pre-existing rest-pose path and the new idle-frame path) — the two sites' surrounding per-index LOOP shapes stayed separate (paperdoll walks an already-filtered `WorldEntity.MeshRefs`; chargen walks the pre-filter Setup-part-indexed scratch list), matching the MUST-COVER's own "only if it stays clean" bar. **Bookkeeping:** TS-83 retired in `docs/architecture/retail-divergence-register.md` (§4 count 50→49, row removed, RETIRED clause added to the header narrative); the CC6a ledger row above now cites its real commit SHAs (`55bfd9ca`, `1774d8b2`) instead of "HEAD of `campaign-cc6a`". **Tests:** `RetailAnimationCyclePlaybackTests` (10, Core), `ChargenAppearanceFactoryTests` (+4, the override precedence/sentinel), `ChargenPreviewRotationControllerTests` (10, +1 this fix round — F7's clockwise-past-360 clamp case), `ChargenPreviewZoomControllerTests` (9, +2 this fix round — F2's null-ctor-throws and read-through-no-independent-state cases; every pre-existing case rewritten for the now-required-animator constructor), `ChargenPreviewAnimatorTests` (7, hand-built fixtures — no dat needed since a `ChargenPreviewAnimatedBuild` is constructible entirely in memory), `ChargenPreviewEntityBuilderTests` (+5, installed-DAT-gated — `TryBuildAnimated` resolves a real idle cycle for Aluvian AND Olthoi, the unknown-setup null path, both Olthoi/OlthoiAcid shared enum keys resolve to a real installed DID). Counts: Core.Tests 4786/1 skip (unchanged this fix round — F1-F7 were doc/API-shape/allocation fixes, no new Core tests), Content.Tests 147/0 skips (unchanged), App.Tests 5152/6 skips (+3 from 5149/6, the F2/F7 additions) — zero failures, full solution Release build green. Two PRE-EXISTING flakes noted across repeated full-solution runs, neither caused by this round and neither reproducing in isolation: `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` (randomized-loss-injection timing, zero files under `src/AcDream.Core.Net/` touched) and `AcDream.Content.Tests.DecodedTextureCacheTests.GetOrCreate_ConcurrentMissRunsFactoryOnce` (a concurrency race under full-solution parallel load, zero files under `src/AcDream.Content/` touched this round either) — both pass 100% run standalone; both projects' full suites otherwise pass clean. **OWED (CC6b page-mount half, separate follow-up):** the Appearance/Summary viewport mount (`0x100003bb`/`0x10000406`), binding the Zoom In/Out and Rotate Clockwise/Counter-Clockwise buttons to `ChargenPreviewZoomController.ZoomIn`/`ZoomOut` (now parameterless — F2 made the animator a required constructor dependency, not a per-call argument) and `ChargenPreviewRotationController.Toggle`/`Tick`, spin controls, color wheels, and the INITIAL HEADING: `gmCGAppearancePage::InitializePage @0x0047FDD0` sets `m_fCurHeading = 180f` at `0x00480235` and pushes it via `SetPlayerHeading` at `0x0048023F` (overriding the ctor’s 0°; cross-confirmed at `gmBarberUI::PostInit @0x004DE330` and the summary page’s `0x0047BD54`) — the mount half must seed `ChargenPreviewRotationController.HeadingDegrees = 180f` or the character faces AWAY from the camera at the user gate. **Explicitly NOT owed:** an option checkbox for Penumbraen-crown/Undead-no-flame variants — see item 4's enclosing-function table above; `gmCGAppearancePage` never had one, so CC6b-mount must not invent one. | | CC7 | — | | | | +| CC6b-MOUNT | CODE-COMPLETE 2026-08-15 (the page-mount half CC6b-PRE deferred — Appearance page, spin controls, color-wheel family, viewport wiring — landing after CC4 merged, closing out Campaign CC's CC6 slice) | (this commit) | OWED (dual-lens review pending — Sonnet-implementation session only) | **Appearance page** (`CharacterCreationAppearancePage`, `src/AcDream.App/UI/Layout/`, wired into `CharacterCreationUiController` beside the four sibling pages): gender buttons (`0x100003a7`/`a8` -> `SelectGender(2)`/`SelectGender(1)`, decomp `ListenToElementMessage` cases `0x9d`/`0x9e`); Face/Clothes sub-tabs (`0x100003a9`/`aa`, cases `0x9f`/`0xa0`) toggling the `0x100003ae`/`b4` choice containers and defaulting the "current part" to Hair/Headgear respectively; nine spin controls (hair/eyes/nose/mouth/skin `0x100003af-b3`, headgear/shirt/trousers/footwear `0x100003b5-b8`) reproducing retail's two-arrow-plus-body-click composite through `UiButton.OnClickAt`'s local x coordinate — decrement zone x=[80,127), increment zone x=[127,174), else selects the part with no index change (cases `0xa5-0xa9` and their headgear/shirt/trousers/footwear mirrors) — since `DatWidgetFactory` consumes each spin's two locally-reused arrow children (`0x1000030a`/`0x1000030b`) into ONE flat `UiButton` with no separate addressable arrow widget; nine color swatches (`0x1000030f-0x10000317` -> `SetColor(0..8)`, gated on the current part's own color-list length exactly like retail's `iNumColors > N` check); the shade scrollbar (`0x10000321`) bound via `ScalarChanged`; zoom/rotate buttons delegating to a late-bound `IChargenPreviewControl` seam. **Per-part routing table** (`StyleSlotFor`/`ColorSlotFor`/`ShadeSlotFor`), decomp-derived from `SetColor @0x0047DD50` and `SetShade @0x0047C860`: Hair has its own color AND shade; Eyes has color but NO shade (retail's `SetShade` switch has no case 1 — independently confirmed against CC6a's own "eye color has no shade indirection" finding); Nose/Mouth/Skin have NO color and ALL route their shade to SKIN shade (cases 2/3/4 share one decompiled body — a genuine retail quirk, not a porting shortcut); Headgear/Shirt/Trousers/Footwear each have their own color and shade. **Wrap semantics** (`CharacterCreationAppearancePage.CycleIndex`, internal static, unit-tested via 10 `[Theory]` cases): plain `[0,count)` modulo wrap for every style spin except Headgear; Headgear alone gets the decomp-derived `(count+1)`-position RING including the `Unset` ("no headgear") position — `CharGenState::SetHeadgearStyle`'s literal signed-int32 comparison shape (`0x0047F4B5`-`0x0047F530` decrement, `0x0047F7D8` increment): decrementing FROM style 0 lands on Unset, incrementing FROM Unset lands on style 0, decrementing FROM Unset wraps to the LAST style, incrementing past the last style lands on Unset — a real closed ring of `count+1` positions, not a plain wrap. Non-headgear spins have no decomp-observable Unset-starting-point case (retail always has a real index by the time the user can click — see AP-214) so a first click from Unset in EITHER direction starts at style 0 (a documented, non-retail-cited edge-case default, not a guess dressed as a citation). **Heritage 6/0xc/0xd gate** (`gmCGAppearancePage::Update @~0x0047EB46-0x0047EE95`): Gearknight/Olthoi/OlthoiAcid hide the Clothes sub-tab (making all four clothing spins unreachable, matching the OWED item's "four clothing spins hidden" framing through retail's OWN mechanism — hiding the tab, not each spin individually) plus the Nose/Mouth spins directly, and disable the Eyes spin's arrows (`_eyesArrowsDisabled`, since Olthoi/Gearknight forms have fixed eyes); forces `SetChoice(FACE)` if Clothes was showing when the gate engages. **Preview wiring** (`ChargenPreviewController`, `src/AcDream.App/Rendering/`, new): bridges a real architectural gap the CC6a/CC6b-PRE foundation left open — `ChargenPreviewRenderer` only ever built its OWN private `ChargenPreviewCamera` with no injection seam, but `ChargenPreviewZoomController` needs a SETTABLE camera to tween. Fixed at the root: `ChargenPreviewViewportCamera` gained a `ChargenPreviewCamera`-accepting constructor overload, `ChargenPreviewRenderer` gained an optional `camera` parameter using it, and `ChargenPreviewController` owns the ONE shared `ChargenPreviewCamera` instance handed to both. `ChargenPreviewController` consolidates the per-frame `IPrivateEntityViewportFrame` owner role (mirrors `PaperdollFramePresenter`, self-timing via `Stopwatch` rather than touching the shared frame-phase interface) with the `IChargenPreviewControl` seam the page's buttons bind against (constructed before the graphics backend exists, so the page cannot receive the real renderer at construction time — assigned late by `LivePresentationComposition`, exactly mirroring the paperdoll's own late `viewport.Renderer = ...` assignment). `Rebuild` recomposes via `ChargenAppearanceFactory.TryCompose` + `ChargenPreviewEntityBuilder.TryBuildAnimated` on ANY heritage/gender/appearance-selection change (no-op if identical to the last composed selection) but only SNAPS the camera to the heritage's default eye on a HERITAGE OR GENDER change (decomp-cited: `gmCGAppearancePage::Update`'s only two confirmed direct call sites are `InitializePage` and the two gender-button handlers; spin/color/shade changes call the narrower `SetSelection`/`SetColor`/`SetShade`, none of which touch `m_vectCurPosition`) — a fresh `ChargenPreviewAnimator` is unavoidable on every rebuild (it owns the resolved drawable-part list, which changes with the mesh) but is immediately restored to the PREVIOUS zoom state via `SetZoomedIn`, and the CURRENT accumulated rotation heading (not the retail default) is threaded into the rebuild, matching retail's `m_bZoomedIn`/`m_fCurHeading` both living on the PAGE and surviving `Update`. Mounted as the THIRD private creature viewport beside paperdoll/creature-appraisal: `RetailUiRuntime` gained `ChargenPreviewViewportWidget`/`ChargenPreviewControl`/`IsChargenPreviewPageVisible` (computed through `CharacterCreationUiController`'s new `AppearanceViewport`/`AppearancePreviewControl`/`IsAppearancePageVisible`, the last one gating on BOTH the page root's own Visible AND the whole screen's `Root.Visible` since `Close()` only ever hides the latter); `LivePresentationComposition` constructs the renderer+catalog+controller and wires `viewport.Renderer`/`page.PreviewControl` through the same lease/`AdoptRelease` pattern paperdoll uses; `FrameRootComposition`'s `PrivateEntityViewportFrameGroup` gained the controller as its third member; `GameWindow`/`GameWindowLifetime` gained the matching guard fields and `RenderShutdownRoots` disposal entries. **Testability seam:** `IChargenPreviewRenderer`/`IChargenPreviewFrameView` (mirroring `IPaperdollDollRenderer`/`IPaperdollFrameView`) let `ChargenPreviewControllerTests` (6 cases, installed-DAT-gated, fake renderer/view — no live GPU) exercise the REAL `ChargenAppearanceFactory`/`ChargenPreviewEntityBuilder` composition path against the installed EoR dat: same-selection no-op, heritage-change camera reset, appearance-only-change camera preservation, zoom-state preservation across an appearance rebuild, the 180° heading actually reaching the built entity's `Rotation` after `Render()`, and the invisible-page render skip. **Color-wheel scouting (campaign plan risk item 4, RESOLVED via live-DAT probe against the installed EoR dat — `CharacterCreationLiveDatTests.AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport`/`AppearancePage_SpinArrowGeometryIsUniformAcrossAllNineSpins`):** NO new `DatWidgetFactory` widget type was needed anywhere on this page. The nine swatch buttons author Type 1 -> `UiButton`; their nine Type-3 companion "selected"-ring overlays (`0x10000318-0x10000320`) and the GradCircle (`0x1000030e`) author Type 3 -> the generic `UiDatElement` fallback; the shade scrollbar (`0x10000321`) authors Type 0xB -> `UiScrollbar`, matching the decomp's own `DynamicCast(0xb)`. The nine spin containers and their two locally-reused arrow children all author Type 1 -> `UiButton`. Two narrow, DECIDED visual substitutions from this finding are filed as AP-215: swatches use their own `.Selected` highlight instead of toggling the separate companion overlay (retail's `SetColor`'s `m_tColorWheel[...]->SetVisible` mechanism), and the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name) show a 1-based ordinal instead of retail's icon thumbnail; the four clothing spins DO show their real `ChargenGearOption.Name`. **The `@140355` gender-flip-on-init oddity (campaign plan risk item 5, RESOLVED via decomp alone — no live cdb needed):** `gmCGAppearancePage::InitializePage`'s own gender-read-then-FLIP-to-the-opposite code (`~0x004802DA-0x00480303`) is real and ALWAYS fires, because `gmCharGenMainUI`'s own constructor (`~0x004e81f5-0x004e8218`, BEFORE any page constructs) calls `CharGenState::RandomizeCharacter(state, hasToD) @0x005c6d80` — retail's chargen screen is NEVER actually blank on open; it always starts with a fully random heritage/gender/appearance/clothing/template/start-area already rolled, which the Appearance page's own init code then immediately flips to the opposite gender. Filed as AP-214, the same unported-primitive gap AP-212 already tracks for the Random button (`RandomizeHeritageGroup`/`RandomizeAppearance`/`RandomizeClothing`/`RandomizeTemplate`/`RandomizeStartArea` are the SAME six primitives `RandomizeCharacter` calls) — acdream's chargen screen opens honestly blank instead, by design, this round. **AD-101 RETIRED** (register §2, 79->78 active rows): `CharacterCreationHeritagePage.Select` no longer auto-selects a gender after a heritage click — the Appearance page's real gender buttons are now the only gender-selection path, matching the review fix round's own retirement-sequencing correction (must land no later than CC5's Finish un-ghosting, which it does — CC5 has not yet un-ghosted Finish). Retail's own default is verified NOT blank (AP-214, above) but acdream's honest-blank choice is deliberate, not an oversight. Updated `CharacterCreationUiControllerTests`'s shared fixture (`FakeRuntime`/`BuildOptions`) with real non-empty Hair/Eyes/Nose/Mouth/Headgear/Shirt/Trousers/Footwear/ClothingColors lists (previously all empty placeholders — no existing test depended on the empty state) and a real `BuildAppearancePage()` layout fixture (uniform spin geometry matching the live-DAT-measured 80/127/174 zone boundaries) so the new dispatch tests exercise the SAME `OnClickAt` zone math production code uses; the one pre-existing gender-side-effect assertion (`HeritageButton_SelectsHeritage_AndAutoSelectsFirstGender`) is renamed/corrected to assert NO gender side effect. **TS-82 NARROWED** (register §4): closed out for the Appearance page specifically (now real, not content-inert) — the row now covers Summary only, CC5's remaining scope. **Register bookkeeping this commit:** AD-101 retired (row deleted, count 79->78); AP-214 filed (the `RandomizeCharacter`-at-ctor / gender-flip finding, count 149->150); AP-215 filed (the two Appearance-page visual substitutions, count 150->151); TS-82 narrowed (Summary-only, count unchanged). **Scope-addendum work (folded into this same commit, not a separate round):** `ChargenPreviewRotationController.HeadingDegrees`'s doc comment corrected to name BOTH the ctor's `0f` (`gmCGAppearancePage::gmCGAppearancePage @0x0047CDAC`) and `InitializePage`'s override to `180f` (`@0x0047FDD0`, write at `0x00480235`, pushed via `SetPlayerHeading` at `0x0048023F`) as retail's OPERATIVE starting heading; DECIDED to change the controller's own parameterless-constructor default from `0f` to a new `RetailDefaultHeadingDegrees = 180f` constant (option (b) of the two offered) rather than requiring every future mount site to remember a separate "seed to 180" call at construction — every real `gmCG3DView` owner (Appearance, Summary `@0x0047BD54` — confirmed a SEPARATE `gmCG3DView` instance/page, CC5's own scope, not touched here — and `gmBarberUI`) converges on 180° before its first visible frame, so a controller whose default silently faces the character away from the camera is exactly the trap the addendum warned about; existing pure-math tests updated to pass `0f` explicitly (keeps their relative-delta assertions simple and unchanged in meaning) plus one new test pinning the parameterless-constructor 180° default at the seam a real consumer experiences, and a second, end-to-end confirmation inside `ChargenPreviewControllerTests` that `Render()` actually applies that heading to the built entity's `Rotation`. **Tests:** `CharacterCreationLiveDatTests` (+2 permanent structural/geometry tests replacing the temporary scouting probe), `CharacterCreationUiControllerTests` (+23: gender/spin/wrap/swatch/shade/zoom-rotate dispatch, the Olthoi clothing-hide gate, the 10-case `CycleIndex` wrap-semantics theory, the renamed AD-101 test), `ChargenPreviewControllerTests` (+6, new file, installed-DAT-gated), `ChargenPreviewRotationControllerTests` (+1, the 180°-default pin). Counts (Release, full solution, `ACDREAM_PROBE_LIVE_MOUNT=1` + `ACDREAM_DAT_DIR` set so every installed-DAT-gated test in this round actually runs rather than skip-gating): Runtime 1713/0 (unchanged — `SetAppearanceIndex`/`SetShade` command plumbing already existed in `IRuntimeCharacterCreationCommands`/`GameRuntimeCommands.cs` from CC3, nothing new needed there), Core 4786/1 skip (unchanged), Content 147/0 (unchanged), App 5220/3 skips (5208/15 skips without the probe env vars — the 12-skip delta is exactly the installed-DAT-gated tests this round adds/exercises), Headless 166/0 (unchanged) — zero failures across two consecutive full-solution runs; one transient failure in `AcDream.Core.Net.Tests.Transport.NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConverge` reproduced on the FIRST full-solution run and passed clean both in isolation and on an immediate full-solution re-run — the SAME pre-existing, previously-documented flake CC6b-PRE's own ledger row already names (randomized-loss-injection timing, zero files under `src/AcDream.Core.Net/` touched this round either). **OWED for CC5+ / future:** the actual retail-icon rendering pipeline for hair/eyes/nose/mouth style spins and the GradCircle's own interactive click-to-hue behavior (AP-215 both name this — the GradCircle is currently a non-interactive static container this round, since its own click-to-color-position mapping has no decomp citation yet and the nine swatch buttons already provide a full, decomp-cited color-selection path); a real `RandomizeCharacter` port (AP-214/AP-212's shared landing site) if a future connected gate wants retail's true randomized-on-open default instead of acdream's honest-blank one; the exact pixel-identical companion-overlay swatch highlight (AP-215) if a future visual gate demands it. | diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index f84a8a6e..6f1deeb5 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -539,7 +539,8 @@ internal sealed class FrameRootCompositionPhase renderFrameResources, new PrivateEntityViewportFrameGroup( live.PaperdollPresenter, - live.CreatureAppraisalPresenter), + live.CreatureAppraisalPresenter, + live.ChargenPreviewController), retainedGameplayUi, // The ImGui developer-tools frontend was removed at Campaign V // slice V11; this optional hook is unbound until a follow-up diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 84ae9175..53ab9be4 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -996,6 +996,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory late.GameRuntime.CharacterCreationSelectStartArea, late.GameRuntime.CharacterCreationFinish, RequestExit: () => { }, + SetAppearanceIndex: late.GameRuntime.CharacterCreationSetAppearanceIndex, + SetShade: late.GameRuntime.CharacterCreationSetShade, ResolveText: key => { lock (d.DatLock) diff --git a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs index 8ea0680c..f61e0651 100644 --- a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs +++ b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs @@ -227,6 +227,20 @@ internal sealed class DeferredGameRuntimeStateCommands Invoke((commands, generation) => commands.CharacterCreation.Finish(generation, confirmUnspentCredits)); + // ── Campaign CC slice CC6b-MOUNT: Appearance page commands ─────────── + + public RuntimeCommandResult CharacterCreationSetAppearanceIndex( + ChargenAppearanceSlot slot, + uint index) => + Invoke((commands, generation) => + commands.CharacterCreation.SetAppearanceIndex(generation, slot, index)); + + public RuntimeCommandResult CharacterCreationSetShade( + ChargenShadeSlot slot, + double value) => + Invoke((commands, generation) => + commands.CharacterCreation.SetShade(generation, slot, value)); + // ── Campaign FA slice FA4: fellowship page commands ───────────────── // Same "capture view+commands under one generation" shape as every // method above — a displaced session (reconnect mid-click) can never diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index 6c481669..92f2df7b 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -125,6 +125,13 @@ internal sealed record LivePresentationResult( PaperdollFramePresenter? PaperdollPresenter, CreatureAppraisalViewportRenderer? CreatureAppraisalRenderer, CreatureAppraisalFramePresenter? CreatureAppraisalPresenter, + // Campaign CC slice CC6b-MOUNT: the chargen Appearance-page preview — + // the renderer (leased/disposed) and the controller (per-frame owner + + // late-bound zoom/rotate control surface) are separate fields because + // ChargenPreviewController does not own the renderer's lifetime (it is + // a leased composition resource, mirroring PaperdollViewportRenderer). + ChargenPreviewRenderer? ChargenPreviewRenderer, + ChargenPreviewController? ChargenPreviewController, WbFrustum EnvCellFrustum, EnvCellRenderer? EnvCellRenderer, LandblockPresentationPipeline LandblockPipeline, @@ -985,6 +992,67 @@ internal sealed class LivePresentationCompositionPhase new RetailCreatureAppraisalCloneFactory( new LiveCreatureAppraisalEntityLookup(liveEntities))); } + + // Campaign CC slice CC6b-MOUNT: the chargen Appearance-page preview. + // Same "both arms exist, needs a dispatcher + the retained-UI + // viewport widget" shape as paperdoll/creature-appraisal above — + // this is the THIRD private creature viewport, not a new pattern. + CompositionAcquisitionScope.CompositionAcquisitionLease< + ChargenPreviewRenderer>? chargenPreviewLease = null; + ChargenPreviewController? chargenPreviewController = null; + if (dispatcherLease.Resource is { } chargenDispatcher + && interaction.RetainedUi?.Runtime.ChargenPreviewViewportWidget is { } chargenViewport) + { + var chargenCamera = new ChargenPreviewCamera(); + chargenPreviewLease = scope.Acquire( + "chargen preview viewport", + () => new ChargenPreviewRenderer( + worldPassScope + ?? throw new InvalidOperationException( + "The graphics backend must publish a world pass scope."), + host.GpuDevice, + host.GpuFrameLifetime, + chargenDispatcher, + foundation.SceneLighting!, + foundation.TextureCache, + foundation.MeshAdapter!, + camera: chargenCamera), + static value => value.Dispose()); + IUiViewportRenderer? previousChargenRenderer = chargenViewport.Renderer; + chargenViewport.Renderer = chargenPreviewLease.Resource; + bindings.AdoptRelease( + "chargen preview viewport target", + () => + { + if (ReferenceEquals(chargenViewport.Renderer, chargenPreviewLease.Resource)) + chargenViewport.Renderer = previousChargenRenderer; + }); + + var chargenCatalog = new AcDream.Content.CharGen.ChargenAppearanceCatalog(content.Dats); + chargenPreviewController = new ChargenPreviewController( + chargenPreviewLease.Resource, + chargenCamera, + new RetailChargenPreviewFrameView( + chargenViewport, + new RetailChargenPreviewPageVisibility(interaction.RetainedUi.Runtime)), + content.Dats, + content.AnimationLoader, + chargenCatalog, + chargenCatalog, + d.DatLock); + interaction.RetainedUi.Runtime.ChargenPreviewControl = chargenPreviewController; + bindings.AdoptRelease( + "chargen preview control", + () => + { + if (ReferenceEquals( + interaction.RetainedUi.Runtime.ChargenPreviewControl, + chargenPreviewController)) + { + interaction.RetainedUi.Runtime.ChargenPreviewControl = null; + } + }); + } Fault(LivePresentationCompositionPoint.PrivateCreatureViewportsCreated); var envCellFrustum = new WbFrustum(); @@ -1291,6 +1359,8 @@ internal sealed class LivePresentationCompositionPhase paperdollPresenter, creatureAppraisalLease?.Resource, creatureAppraisalPresenter, + chargenPreviewLease?.Resource, + chargenPreviewController, envCellFrustum, envCellLease.Resource, landblockPipeline, diff --git a/src/AcDream.App/Rendering/ChargenPreviewCamera.cs b/src/AcDream.App/Rendering/ChargenPreviewCamera.cs index 98db610d..5ddabf9f 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewCamera.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewCamera.cs @@ -157,6 +157,20 @@ internal sealed class ChargenPreviewViewportCamera : IPrivateEntityViewportCamer _camera = new ChargenPreviewCamera(heritageId); } + /// + /// CC6b-MOUNT seam: wraps an EXTERNALLY-owned + /// instead of constructing a private one. + /// needs a settable to tween — the + /// other constructor's private _camera field is unreachable from + /// outside this class, so the page-mount composition (which owns the + /// zoom controller) must supply the SAME camera instance both this + /// adapter and the zoom controller mutate/read. + /// + public ChargenPreviewViewportCamera(ChargenPreviewCamera camera) + { + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + } + public void SetHeritage(uint heritageId) => _camera.SetHeritage(heritageId); public Vector3 Eye => _camera.Eye; diff --git a/src/AcDream.App/Rendering/ChargenPreviewController.cs b/src/AcDream.App/Rendering/ChargenPreviewController.cs new file mode 100644 index 00000000..91c1e669 --- /dev/null +++ b/src/AcDream.App/Rendering/ChargenPreviewController.cs @@ -0,0 +1,298 @@ +using System.Diagnostics; +using System.Numerics; +using AcDream.App.UI; +using AcDream.Content; +using AcDream.Core.CharGen; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using DatReaderWriter; + +namespace AcDream.App.Rendering; + +/// +/// Campaign CC slice CC6b-MOUNT: the page-mount half's control surface over +/// the CC6a/CC6b-PRE preview foundation. +/// is constructed BEFORE the graphical presentation pipeline exists (early +/// retained-UI composition — see 's +/// own late-bound-Func doc comment), so its zoom/rotate buttons bind against +/// this interface's default no-op-until-assigned shape rather than a +/// concrete renderer reference. +/// constructs the real once the +/// graphics backend exists and assigns it onto the page — mirroring exactly +/// how the paperdoll's viewport.Renderer = paperdollLease.Resource +/// late-assignment already works for a DIFFERENT screen's viewport. +/// +internal interface IChargenPreviewControl +{ + /// + /// Recomposes and rebuilds the preview entity when the heritage/gender/ + /// appearance selection actually changed since the last call (a cheap + /// no-op otherwise). Returns false when the selection cannot be + /// resolved/built (heritage or gender not yet chosen, or a missing dat + /// resource) — the caller (the page) simply leaves the previous frame on + /// screen, matching PaperdollFramePresenter's own + /// "keep the successful doll, retry next visible frame" precedent. + /// + bool Rebuild( + ChargenOptions options, + uint heritageId, + int genderKey, + ChargenAppearanceSelection selection); + + void ZoomIn(); + void ZoomOut(); + void RotateClockwise(); + void RotateCounterClockwise(); +} + +/// Gates the preview's per-frame work on whether the Appearance +/// PAGE (not just the leaf viewport widget) is the currently visible page — +/// mirrors IPaperdollInventoryVisibility's outer-frame gate. +internal interface IChargenPreviewPageVisibility +{ + bool IsVisible { get; } +} + +/// CC6b-MOUNT: narrow seam mirroring IPaperdollFrameView so +/// can be exercised with a fake view +/// in tests. +internal interface IChargenPreviewFrameView +{ + bool TryGetVisibleSize(out int width, out int height); + + void SetTextureHandle(uint textureHandle); +} + +/// Thin adapter over RetailUiRuntime.IsChargenPreviewPageVisible +/// — narrowed to so this +/// Rendering-namespace class doesn't need a direct dependency on the +/// UI/Layout-namespace RetailUiRuntime type beyond the one property +/// read. +internal sealed class RetailChargenPreviewPageVisibility : IChargenPreviewPageVisibility +{ + private readonly AcDream.App.UI.RetailUiRuntime _runtime; + + public RetailChargenPreviewPageVisibility(AcDream.App.UI.RetailUiRuntime runtime) => + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + + public bool IsVisible => _runtime.IsChargenPreviewPageVisible; +} + +/// Retained-UI visibility + texture publication, mirroring +/// RetailPaperdollFrameView. +internal sealed class RetailChargenPreviewFrameView : IChargenPreviewFrameView +{ + private readonly UiViewport _viewport; + private readonly IChargenPreviewPageVisibility _page; + + public RetailChargenPreviewFrameView( + UiViewport viewport, + IChargenPreviewPageVisibility page) + { + _viewport = viewport ?? throw new ArgumentNullException(nameof(viewport)); + _page = page ?? throw new ArgumentNullException(nameof(page)); + } + + public bool TryGetVisibleSize(out int width, out int height) + { + width = 0; + height = 0; + if (!_viewport.Visible || !_page.IsVisible) + return false; + + width = (int)_viewport.Width; + height = (int)_viewport.Height; + return true; + } + + public void SetTextureHandle(uint textureHandle) => + _viewport.TextureSlot = UiTextureTableHandle.ToSlot(textureHandle); +} + +/// +/// The real, dat-touching implementation of +/// plus the per-frame owner — +/// constructed once in +/// (same composition scope RetailPaperdollPoseApplicator is built in, +/// which has the real content.Dats/content.AnimationLoader/ +/// d.DatLock) and assigned onto the already-mounted Appearance page. +/// +/// +/// Camera/zoom/rotation ownership (CC6b-MOUNT bridges a CC6a/CC6b-PRE gap): +/// only ever built its OWN private +/// with no injection seam, but +/// needs a SETTABLE camera to +/// tween. This class owns the ONE +/// instance and hands it to the renderer via the new +/// overload, +/// so both the renderer's draw and the zoom controller's tween read/write +/// the exact same eye position. +/// +/// +/// +/// Rebuild vs per-frame ownership split, decomp-cited (retail +/// gmCGAppearancePage::Update @ 0x0047E8F0): the camera SNAPS to +/// the heritage's default (zoomed-in) eye only on a HERITAGE or GENDER +/// change (the two confirmed direct call sites of the outer Update — +/// InitializePage and the two gender-button handlers, +/// ListenToElementMessage cases 0x9d/0x9e) — spin/color/ +/// shade changes call the narrower SetSelection/SetColor/ +/// SetShade instead, none of which touch m_vectCurPosition. +/// reproduces that split: it always recomposes the +/// ObjDesc/mesh (every appearance field feeds gmCG3DView::Update's +/// rebuild eventually), but only resets the camera when heritage or gender +/// actually changed. m_fCurHeading (this class's +/// ) and m_bZoomedIn +/// (read through ) both live +/// on the PAGE in retail and are NEVER reset by Update — so a fresh +/// (unavoidable: it owns the resolved +/// drawable-part list, which changes with the mesh) is immediately restored +/// to the PREVIOUS zoom state, and the current accumulated heading is passed +/// into the rebuild rather than resetting to the retail default. +/// +/// +internal sealed class ChargenPreviewController : + IChargenPreviewControl, + IPrivateEntityViewportFrame, + IDisposable +{ + private readonly IChargenPreviewRenderer _renderer; + private readonly IChargenPreviewFrameView _view; + private readonly ChargenPreviewCamera _camera; + private readonly ChargenPreviewRotationController _rotation; + private readonly IDatReaderWriter _dats; + private readonly IAnimationLoader _animations; + private readonly IChargenPalSetSource _palSets; + private readonly IChargenClothingTableSource _clothingTables; + private readonly object _datLock; + private readonly Stopwatch _clock = Stopwatch.StartNew(); + + private ChargenPreviewAnimator? _animator; + private ChargenPreviewZoomController? _zoom; + private double _lastElapsedSeconds; + private bool _hasComposed; + private uint _lastHeritageId; + private int _lastGenderKey = -1; + private ChargenAppearanceSelection _lastSelection; + private bool _disposed; + + /// The SAME instance passed to the + /// 's own camera constructor + /// parameter — see this class's own doc comment on why the renderer and + /// the zoom controller must share one mutable camera. + public ChargenPreviewController( + IChargenPreviewRenderer renderer, + ChargenPreviewCamera camera, + IChargenPreviewFrameView view, + IDatReaderWriter dats, + IAnimationLoader animations, + IChargenPalSetSource palSets, + IChargenClothingTableSource clothingTables, + object datLock) + { + _renderer = renderer ?? throw new ArgumentNullException(nameof(renderer)); + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + _view = view ?? throw new ArgumentNullException(nameof(view)); + _dats = dats ?? throw new ArgumentNullException(nameof(dats)); + _animations = animations ?? throw new ArgumentNullException(nameof(animations)); + _palSets = palSets ?? throw new ArgumentNullException(nameof(palSets)); + _clothingTables = clothingTables ?? throw new ArgumentNullException(nameof(clothingTables)); + _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock)); + _rotation = new ChargenPreviewRotationController(); + } + + /// Test-observability seam only — production callers use + /// /. + internal bool IsZoomedIn => _zoom?.IsZoomedIn ?? false; + + /// Test-observability seam only. + internal Vector3 CameraEye => _camera.Eye; + + public bool Rebuild( + ChargenOptions options, + uint heritageId, + int genderKey, + ChargenAppearanceSelection selection) + { + if (_disposed) + return false; + + if (_hasComposed + && heritageId == _lastHeritageId + && genderKey == _lastGenderKey + && selection.Equals(_lastSelection)) + { + return true; + } + + if (!ChargenAppearanceFactory.TryCompose( + options, heritageId, genderKey, selection, + _palSets, _clothingTables, out ChargenAppearanceResult result)) + { + return false; + } + + Quaternion heading = MoveToMath.SetHeading( + Quaternion.Identity, _rotation.HeadingDegrees); + ChargenPreviewAnimatedBuild? build = ChargenPreviewEntityBuilder.TryBuildAnimated( + _dats, _animations, result, heritageId, heading, _datLock); + if (build is null) + return false; + + bool wasZoomedIn = _animator?.IsZoomedIn ?? false; + _animator = new ChargenPreviewAnimator(build); + if (wasZoomedIn) + _animator.SetZoomedIn(true); + + bool heritageOrGenderChanged = + !_hasComposed || heritageId != _lastHeritageId || genderKey != _lastGenderKey; + if (heritageOrGenderChanged) + _camera.SetHeritage(heritageId); + + // ChargenPreviewZoomController's animator dependency is required at + // construction (fix round F2) — a fresh animator means a fresh + // controller, but it reads IsZoomedIn straight through the animator + // we just restored above, so zoom state itself survives the swap. + _zoom = new ChargenPreviewZoomController(heritageId, _camera, _animator); + + _renderer.SetPreview(_animator.Entity); + _hasComposed = true; + _lastHeritageId = heritageId; + _lastGenderKey = genderKey; + _lastSelection = selection; + return true; + } + + public void ZoomIn() => _zoom?.ZoomIn(); + public void ZoomOut() => _zoom?.ZoomOut(); + public void RotateClockwise() => _rotation.Toggle(ChargenRotateDirection.Clockwise); + public void RotateCounterClockwise() => _rotation.Toggle(ChargenRotateDirection.CounterClockwise); + + public void Render() + { + if (_disposed || !_view.TryGetVisibleSize(out int width, out int height)) + return; + + double now = _clock.Elapsed.TotalSeconds; + float deltaSeconds = (float)Math.Max(0.0, now - _lastElapsedSeconds); + _lastElapsedSeconds = now; + + _animator?.Tick(deltaSeconds); + _rotation.Tick(now); + _zoom?.Tick(now); + if (_animator is not null) + _animator.Entity.Rotation = _rotation.ToOrientation(); + + _view.SetTextureHandle(_renderer.Render(width, height)); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + // The renderer itself is a leased composition resource disposed by + // the composition root (mirrors PaperdollViewportRenderer — this + // class does not own its lifetime, only its per-frame drive). + } +} diff --git a/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs b/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs index fa6a7490..91d58ee9 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewRenderer.cs @@ -5,6 +5,18 @@ using AcDream.Core.World; namespace AcDream.App.Rendering; +/// +/// CC6b-MOUNT: narrow seam mirroring IPaperdollDollRenderer so +/// 's rebuild/render logic can be +/// exercised with a fake in tests without a live GPU device. +/// +internal interface IChargenPreviewRenderer +{ + void SetPreview(WorldEntity? entity); + + uint Render(int width, int height); +} + /// /// Chargen-specific facade over the shared private creature viewport /// () — CC6a's foundation half of @@ -44,6 +56,7 @@ namespace AcDream.App.Rendering; /// internal sealed class ChargenPreviewRenderer : IUiViewportRenderer, + IChargenPreviewRenderer, IDisposable { private readonly PrivateEntityViewportRenderer _renderer; @@ -57,9 +70,17 @@ internal sealed class ChargenPreviewRenderer : SceneLightingUboBinding lightUbo, IEntityTextureLifetime textureLifetime, IWbMeshAdapter meshAdapter, - uint heritageId = 0u) + uint heritageId = 0u, + ChargenPreviewCamera? camera = null) { - _camera = new ChargenPreviewViewportCamera(heritageId); + // CC6b-MOUNT: when a caller supplies its own camera instance (the + // page-mount composition, which needs a SETTABLE camera for + // ChargenPreviewZoomController to tween — see + // ChargenPreviewController's own doc comment), wrap that exact + // instance instead of building a private, unreachable one. + _camera = camera is not null + ? new ChargenPreviewViewportCamera(camera) + : new ChargenPreviewViewportCamera(heritageId); _renderer = new PrivateEntityViewportRenderer( scope, device, diff --git a/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs b/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs index 323fdb19..265c5da4 100644 --- a/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs +++ b/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs @@ -46,12 +46,54 @@ internal sealed class ChargenPreviewRotationController private ChargenRotateDirection _direction = ChargenRotateDirection.Invalid; private bool _rotating; + /// + /// CC6b-MOUNT: retail's true OPERATIVE starting heading — not the ctor's + /// value. gmCGAppearancePage::gmCGAppearancePage @0x0047CCC0 sets + /// m_fCurHeading = 0f at 0x0047CDAC, but + /// gmCGAppearancePage::InitializePage @0x0047FDD0 — which always + /// runs immediately afterward, before the page is ever visible — writes + /// m_fCurHeading = 180f at 0x00480235 and pushes it into the + /// view via gmCG3DView::SetPlayerHeading(m_p3DView, 180f) at + /// 0x0048023F. No player-visible frame of chargen's Appearance + /// preview is EVER rendered at the ctor's 0° — 180° is the only heading a + /// user actually sees. The same override, independently, is what every + /// other gmCG3DView owner does for ITS own instance: + /// gmCGSummaryPage::InitializePage @0x0047BD54 (a separate + /// viewport/page, CC5's scope, not this one) and + /// gmBarberUI::PostInit (~0x004DE330, pushed at + /// 0x004E03B5) both call the identical + /// SetPlayerHeading(m_p3DView, 180f) for their own pages. Since + /// this controller — like retail's m_fCurHeading — is itself the + /// PAGE-level heading owner (not the view's), matching the value every + /// real page converges on before its first frame is the retail-faithful + /// choice; requiring every future mount site to remember a separate + /// "seed to 180" call would be a trap (a forgotten seed silently faces + /// the character away from the camera). + /// + public const float RetailDefaultHeadingDegrees = 180f; + public bool IsRotating => _rotating; public ChargenRotateDirection Direction => _direction; - /// Retail's m_fCurHeading, degrees, ctor default 0 — - /// applied to the preview entity via MoveToMath.SetHeading - /// (CPhysicsObj::set_heading's exact port). + /// Defaults to + /// (see that constant's doc for + /// the full ctor-vs-InitializePage citation) — the value every real + /// mount site should get for free. Tests that exercise the pure + /// rotation/wrap arithmetic pass 0f explicitly for simpler + /// relative-delta assertions; that is a test convenience, not a second + /// retail-cited default. + public ChargenPreviewRotationController( + float initialHeadingDegrees = RetailDefaultHeadingDegrees) + { + HeadingDegrees = initialHeadingDegrees; + } + + /// Retail's m_fCurHeading, degrees — applied to the + /// preview entity via MoveToMath.SetHeading + /// (CPhysicsObj::set_heading's exact port). See + /// for why this controller's + /// parameterless-constructor default is 180, not the ctor's raw 0. + /// public float HeadingDegrees { get; private set; } /// diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index ac349c78..7fad2da9 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -429,6 +429,11 @@ public sealed class GameWindow : _creatureAppraisalViewportRenderer; private AcDream.App.Rendering.CreatureAppraisalFramePresenter? _creatureAppraisalFramePresenter; + // Campaign CC slice CC6b-MOUNT — the chargen Appearance-page preview, + // same guard/shutdown shape as the paperdoll/creature-appraisal + // viewports above. + private AcDream.App.Rendering.ChargenPreviewRenderer? _chargenPreviewRenderer; + private AcDream.App.Rendering.ChargenPreviewController? _chargenPreviewController; // Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad. private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry; private AcDream.App.Plugins.GraphicalPluginSession? _pluginSession; @@ -1077,6 +1082,8 @@ public sealed class GameWindow : || _paperdollFramePresenter is not null || _creatureAppraisalViewportRenderer is not null || _creatureAppraisalFramePresenter is not null + || _chargenPreviewRenderer is not null + || _chargenPreviewController is not null || _envCellRenderer is not null || _envCellFrustum is not null || _landblockPresentationPipeline is not null @@ -1114,6 +1121,8 @@ public sealed class GameWindow : _paperdollFramePresenter = result.PaperdollPresenter; _creatureAppraisalViewportRenderer = result.CreatureAppraisalRenderer; _creatureAppraisalFramePresenter = result.CreatureAppraisalPresenter; + _chargenPreviewRenderer = result.ChargenPreviewRenderer; + _chargenPreviewController = result.ChargenPreviewController; _envCellFrustum = result.EnvCellFrustum; _envCellRenderer = result.EnvCellRenderer; _landblockPresentationPipeline = result.LandblockPipeline; @@ -1759,6 +1768,8 @@ public sealed class GameWindow : _portalTunnelFallback, _paperdollViewportRenderer, _creatureAppraisalViewportRenderer, + _chargenPreviewRenderer, + _chargenPreviewController, _wbDrawDispatcher, _envCellRenderer, _portalDepthMask, diff --git a/src/AcDream.App/Rendering/GameWindowLifetime.cs b/src/AcDream.App/Rendering/GameWindowLifetime.cs index 92001eae..ca95a57e 100644 --- a/src/AcDream.App/Rendering/GameWindowLifetime.cs +++ b/src/AcDream.App/Rendering/GameWindowLifetime.cs @@ -112,6 +112,8 @@ internal sealed record RenderShutdownRoots( TransferableResourceSlot PortalTunnelFallback, PaperdollViewportRenderer? Paperdoll, CreatureAppraisalViewportRenderer? CreatureAppraisal, + ChargenPreviewRenderer? ChargenPreview, + ChargenPreviewController? ChargenPreviewController, WbDrawDispatcher? DrawDispatcher, EnvCellRenderer? EnvironmentCells, PortalDepthMaskRenderer? PortalDepthMask, @@ -489,6 +491,8 @@ internal static class GameWindowShutdownManifest Hard( "creature appraisal viewport", () => render.CreatureAppraisal?.Dispose()), + Hard("chargen preview control", () => render.ChargenPreviewController?.Dispose()), + Hard("chargen preview viewport", () => render.ChargenPreview?.Dispose()), Hard("mesh draw dispatcher", () => render.DrawDispatcher?.Dispose()), Hard("environment cells", () => render.EnvironmentCells?.Dispose()), Hard("portal depth mask", () => render.PortalDepthMask?.Dispose()), diff --git a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs new file mode 100644 index 00000000..82b4fe60 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs @@ -0,0 +1,692 @@ +using System.Globalization; +using AcDream.App.Rendering; +using AcDream.Core.CharGen; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.App.UI.Layout; + +/// +/// The Appearance page (gmCGAppearancePage, root 0x100003d4) — +/// Campaign CC slice CC6b-MOUNT, the final piece of CC6. Decomp anchors: +/// gmCGAppearancePage::InitializePage @ 0x0047FDD0 (widget ids, the +/// 180° initial heading), ::ListenToElementMessage @ 0x0047EF30 (EVERY +/// dispatch this page implements — gender buttons at cases 0x9d/ +/// 0x9e; Face/Clothes sub-tabs at 0x9f/0xa0; the nine +/// spin controls' decrement/increment arrow children, keyed by PARENT id, at +/// cases 0/1; the same nine spins' own BODY click (select-as- +/// current-part, no index change) at cases 0xa5-0xa9 and the +/// mirrored headgear/shirt/trousers/footwear cases; the nine color swatches +/// at cases 5-0xd -> SetColor(0..8); the shade +/// scrollbar at case 0x17 -> SetShade; rotate at +/// 0x19/0x1a; zoom at 0x1b/0x1c), +/// ::SetColor @ 0x0047DD50 and ::SetShade @ 0x0047C860 (the +/// per-part color/shade routing table this page's switch +/// mirrors, including the NOSE/MOUTH/SKIN-all-route-to-skin-shade quirk and +/// EYES having no shade at all), ::Update @ 0x0047E8F0 (the heritage +/// 6/0xc/0xd Clothes-button + Nose/Mouth-spin hide, Eyes-arrows-disable +/// gate). +/// +/// +/// Spin arrow geometry (live-DAT probe, CC6b-MOUNT): every one of the +/// nine spin elements (0x100003af-b3, b5-b8) is uniformly +/// 200px wide with two LOCALLY-reused arrow child ids (0x1000030a +/// decrement at local x=[80,127), 0x1000030b increment at +/// x=[127,174)) that DatWidgetFactory's UiButton consumes into +/// one flat clickable leaf — there is no separate addressable arrow widget +/// to bind. This page reproduces retail's two-arrow-plus-body-click shape +/// entirely through 's local x coordinate +/// (no new DatWidgetFactory widget type needed — see this campaign's +/// color-wheel scouting finding below, which reached the identical "existing +/// types suffice" conclusion for the whole page). +/// +/// +/// +/// Color-wheel family scouting (campaign plan risk item 4, RESOLVED): +/// a live-DAT probe (CharacterCreationLiveDatTests) found every +/// color-wheel-family id resolves through EXISTING DatWidgetFactory +/// mappings: the nine swatch buttons (0x1000030f-0x10000317, retail's +/// SetColor(0..8) targets) author Type 1 -> UiButton; their +/// nine Type-3 companion "selected" overlays (0x10000318-0x10000320, +/// retail's m_tColorWheel[...][0x10][iCurColor*7]->SetVisible +/// highlight ring) and the GradCircle (0x1000030e) author Type 3 -> +/// UiDatElement; the shade scrollbar (0x10000321) authors +/// Type 0xB -> UiScrollbar, matching the decomp's own +/// DynamicCast(0xb). NO new widget type was added. This page uses the +/// swatch buttons' own state for the +/// highlight instead of toggling the separate companion overlay elements — +/// a documented substitution (same class as AD-103's swallowed-child +/// pattern), not a pixel-identical port of retail's own two-widget +/// mechanism. +/// +/// +/// +/// Icon-only style lists have no name string (register-worthy scope +/// cut): CC1's // +/// carry an IconId, not a name — retail +/// shows an actual icon thumbnail in these four spins (hair/eyes/nose/ +/// mouth). Icon rendering is out of this round's scope; the spin shows a +/// 1-based ordinal instead. The four clothing spins (headgear/shirt/ +/// trousers/footwear) DO carry a real +/// and show it directly. +/// +/// +internal sealed class CharacterCreationAppearancePage : IDisposable +{ + private const uint Unset = RuntimeCharacterCreationAppearance.Unset; + + internal enum Part + { + Hair = 1, + Eyes = 2, + Nose = 3, + Mouth = 4, + Skin = 5, + Headgear = 6, + Shirt = 7, + Trousers = 8, + Footwear = 9, + } + + private enum Choice + { + Face, + Clothes, + } + + internal const uint FemaleButtonId = 0x100003A7u; + internal const uint MaleButtonId = 0x100003A8u; + internal const uint FaceButtonId = 0x100003A9u; + internal const uint ClothesButtonId = 0x100003AAu; + internal const uint FaceChoicesId = 0x100003AEu; + internal const uint ClothesChoicesId = 0x100003B4u; + internal const uint HairSpinId = 0x100003AFu; + internal const uint EyesSpinId = 0x100003B0u; + internal const uint NoseSpinId = 0x100003B1u; + internal const uint MouthSpinId = 0x100003B2u; + internal const uint SkinSpinId = 0x100003B3u; + internal const uint HeadgearSpinId = 0x100003B5u; + internal const uint ShirtSpinId = 0x100003B6u; + internal const uint TrousersSpinId = 0x100003B7u; + internal const uint FootwearSpinId = 0x100003B8u; + internal const uint RotateClockwiseId = 0x10000323u; + internal const uint RotateCounterClockwiseId = 0x10000324u; + internal const uint ZoomInId = 0x10000325u; + internal const uint ZoomOutId = 0x10000326u; + internal const uint GradCircleId = 0x1000030Eu; + internal const uint ShadeScrollId = 0x10000321u; + internal const uint ViewportId = 0x100003BBu; + + /// Retail's nine SetColor(0..8) swatch buttons, in + /// index order — verbatim off ListenToElementMessage's cases + /// 5-0xd (elementId - 0x1000030a). + internal static readonly uint[] SwatchIds = + [ + 0x1000030Fu, 0x10000310u, 0x10000311u, 0x10000312u, 0x10000313u, + 0x10000314u, 0x10000315u, 0x10000316u, 0x10000317u, + ]; + + /// Live-DAT-measured arrow geometry, uniform across all nine + /// spins (every one is 200px wide): decrement child at local + /// x=[80,127), increment child at x=[127,174). Anything outside both + /// zones is the spin's own BODY click (retail cases 0xa5-0xa9 + /// and their headgear/shirt/trousers/footwear mirrors). + private const float DecrementZoneStart = 80f; + private const float IncrementZoneStart = 127f; + private const float IncrementZoneEnd = 174f; + + private readonly CharacterCreationRuntimeBindings _bindings; + private readonly UiButton? _femaleButton; + private readonly UiButton? _maleButton; + private readonly UiButton? _faceButton; + private readonly UiButton? _clothesButton; + private readonly UiElement? _faceChoices; + private readonly UiElement? _clothesChoices; + private readonly Dictionary _spins = []; + private readonly UiButton?[] _swatches = new UiButton?[SwatchIds.Length]; + private readonly UiScrollbar? _shadeScroll; + private readonly UiButton? _rotateClockwise; + private readonly UiButton? _rotateCounterClockwise; + private readonly UiButton? _zoomIn; + private readonly UiButton? _zoomOut; + + private Choice _currentChoice = Choice.Face; + private Part _currentPart = Part.Hair; + private bool _eyesArrowsDisabled; + private bool _disposed; + + /// Late-bound preview control seam — see + /// 's own doc comment for why this + /// page cannot receive the real renderer at construction time. + internal IChargenPreviewControl? PreviewControl { get; set; } + + /// The authored viewport (0x100003bb) — the composition + /// root assigns its Renderer once the graphics backend exists, + /// mirroring the paperdoll's own late viewport.Renderer = ... + /// assignment. + internal UiViewport? Viewport { get; } + + internal CharacterCreationAppearancePage( + UiElement pageRoot, + CharacterCreationRuntimeBindings bindings) + { + _bindings = bindings; + + _femaleButton = Find(pageRoot, FemaleButtonId); + if (_femaleButton is not null) + _femaleButton.OnClick = () => _bindings.SelectGender(2u); + _maleButton = Find(pageRoot, MaleButtonId); + if (_maleButton is not null) + _maleButton.OnClick = () => _bindings.SelectGender(1u); + + _faceButton = Find(pageRoot, FaceButtonId); + if (_faceButton is not null) + _faceButton.OnClick = () => SelectChoice(Choice.Face); + _clothesButton = Find(pageRoot, ClothesButtonId); + if (_clothesButton is not null) + _clothesButton.OnClick = () => SelectChoice(Choice.Clothes); + + _faceChoices = Find(pageRoot, FaceChoicesId); + _clothesChoices = Find(pageRoot, ClothesChoicesId); + + BindSpin(pageRoot, HairSpinId, Part.Hair); + BindSpin(pageRoot, EyesSpinId, Part.Eyes); + BindSpin(pageRoot, NoseSpinId, Part.Nose); + BindSpin(pageRoot, MouthSpinId, Part.Mouth); + BindSpin(pageRoot, SkinSpinId, Part.Skin); + BindSpin(pageRoot, HeadgearSpinId, Part.Headgear); + BindSpin(pageRoot, ShirtSpinId, Part.Shirt); + BindSpin(pageRoot, TrousersSpinId, Part.Trousers); + BindSpin(pageRoot, FootwearSpinId, Part.Footwear); + + for (int i = 0; i < SwatchIds.Length; i++) + { + UiButton? swatch = Find(pageRoot, SwatchIds[i]); + if (swatch is null) + continue; + int index = i; + swatch.OnClick = () => SelectColor(index); + _swatches[i] = swatch; + } + + _shadeScroll = Find(pageRoot, ShadeScrollId); + if (_shadeScroll is not null) + _shadeScroll.ScalarChanged = SetShadeFromScalar; + + Viewport = Find(pageRoot, ViewportId); + + _rotateClockwise = Find(pageRoot, RotateClockwiseId); + if (_rotateClockwise is not null) + _rotateClockwise.OnClick = () => PreviewControl?.RotateClockwise(); + _rotateCounterClockwise = Find(pageRoot, RotateCounterClockwiseId); + if (_rotateCounterClockwise is not null) + _rotateCounterClockwise.OnClick = () => PreviewControl?.RotateCounterClockwise(); + _zoomIn = Find(pageRoot, ZoomInId); + if (_zoomIn is not null) + _zoomIn.OnClick = () => PreviewControl?.ZoomIn(); + _zoomOut = Find(pageRoot, ZoomOutId); + if (_zoomOut is not null) + _zoomOut.OnClick = () => PreviewControl?.ZoomOut(); + + ApplyChoiceVisibility(); + } + + internal void Refresh( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + if (_disposed) + return; + + if (_femaleButton is not null) + _femaleButton.Selected = snapshot.GenderKey == 2u; + if (_maleButton is not null) + _maleButton.Selected = snapshot.GenderKey == 1u; + + // gmCGAppearancePage::Update @ ~0x0047EB46-0x0047EE95: heritage + // 6 (Gearknight) / 0xc (Olthoi) / 0xd (OlthoiAcid) hide the Clothes + // sub-tab (and, with it, every clothing spin behind it), hide the + // Nose/Mouth spins directly, and disable the Eyes spin's arrows — + // none of these three heritages have separate clothing, nose, or + // mouth strip choices. + bool clothesHidden = IsClothesHiddenHeritage(snapshot.HeritageId); + if (_clothesButton is not null) + _clothesButton.Visible = !clothesHidden; + if (_spins.TryGetValue(Part.Nose, out UiButton? noseSpin)) + noseSpin.Visible = !clothesHidden; + if (_spins.TryGetValue(Part.Mouth, out UiButton? mouthSpin)) + mouthSpin.Visible = !clothesHidden; + _eyesArrowsDisabled = clothesHidden; + if (clothesHidden && _currentChoice == Choice.Clothes) + { + // Update forces SetChoice(ECG_CHOICE_FACE) when Clothes becomes + // unreachable so the page never gets stuck showing a hidden tab. + _currentChoice = Choice.Face; + _currentPart = Part.Hair; + } + ApplyChoiceVisibility(); + + if (TryGetGender(view, snapshot, out ChargenGenderOptions? gender)) + RefreshSpins(gender, snapshot.Appearance); + + RefreshColorAndShadeControls(view, snapshot); + RebuildPreview(view, snapshot); + } + + // ── Gender / Face-Clothes sub-tab ────────────────────────────────── + + private void SelectChoice(Choice choice) + { + if (_disposed) + return; + _currentChoice = choice; + // gmCGAppearancePage::ListenToElementMessage cases 0x9f/0xa0: + // Face -> SetSelection(ECG_PARTS_HAIR); Clothes -> + // SetSelection(ECG_PARTS_HEADGEAR). + _currentPart = choice == Choice.Face ? Part.Hair : Part.Headgear; + ApplyChoiceVisibility(); + RefreshColorAndShadeControlsFromLatestSnapshot(); + } + + private void ApplyChoiceVisibility() + { + if (_faceChoices is not null) + _faceChoices.Visible = _currentChoice == Choice.Face; + if (_clothesChoices is not null) + _clothesChoices.Visible = _currentChoice == Choice.Clothes; + if (_faceButton is not null) + _faceButton.Selected = _currentChoice == Choice.Face; + if (_clothesButton is not null) + _clothesButton.Selected = _currentChoice == Choice.Clothes; + } + + // ── Spins (style cycling + select-as-current-part) ───────────────── + + private void BindSpin(UiElement pageRoot, uint id, Part part) + { + UiButton? spin = Find(pageRoot, id); + if (spin is null) + return; + _spins[part] = spin; + + if (part == Part.Skin) + { + // Skin has no style index at all — retail disables its arrow + // children outright (SetAttribute_Bool(...,0xd,1) in + // InitializePage/Update's heritage branches). Every click just + // selects Skin as the current part for the color/shade controls. + spin.OnClickAt = (_, _) => SelectPart(Part.Skin); + return; + } + + spin.OnClickAt = (x, _) => + { + if (x >= DecrementZoneStart && x < IncrementZoneStart) + CycleStyle(part, -1); + else if (x >= IncrementZoneStart && x < IncrementZoneEnd) + CycleStyle(part, +1); + else + SelectPart(part); + }; + } + + private void SelectPart(Part part) + { + if (_disposed) + return; + _currentPart = part; + RefreshColorAndShadeControlsFromLatestSnapshot(); + } + + private void CycleStyle(Part part, int delta) + { + if (_disposed) + return; + if (part == Part.Eyes && _eyesArrowsDisabled) + { + SelectPart(part); + return; + } + + IRuntimeCharacterCreationView? view = _bindings.View(); + if (view is null) + return; + RuntimeCharacterCreationSnapshot snapshot = view.Snapshot; + if (!TryGetGender(view, snapshot, out ChargenGenderOptions? gender)) + return; + + ChargenAppearanceSlot? slot = StyleSlotFor(part); + if (slot is null) + { + SelectPart(part); + return; + } + + int count = StyleCount(part, gender); + uint current = StyleCurrent(part, snapshot.Appearance); + // Headgear alone allows the Unset ("no headgear") ring position — + // CharGenState::SetHeadgearStyle's decomp-derived (count+1)-position + // ring (0..count-1, Unset); every other style spin cycles [0,count). + uint next = CycleIndex(current, delta, count, allowUnset: part == Part.Headgear); + _bindings.SetAppearanceIndex?.Invoke(slot.Value, next); + SelectPart(part); + } + + /// + /// Retail's decomp-derived wrap: reproduces + /// CharGenState::SetHeadgearStyle's literal signed-int32 ring of + /// +1 positions (every real index, plus + /// — decrementing from index 0 lands on Unset, + /// incrementing from Unset lands on index 0, matching + /// ListenToElementMessage's cases 6 exactly). Every other + /// style spin has no decomp-observable Unset-cycling case (retail always + /// has a real 0-based index by the time the user can click — see + /// AP-214's RandomizeCharacter-at-open finding, which acdream + /// does not port this round) — an Unset start there is an edge case + /// retail itself never reaches, so the first click either direction just + /// starts cycling from index 0 rather than reconstructing an unfounded + /// wrap direction. + /// + internal static uint CycleIndex(uint current, int delta, int count, bool allowUnset) + { + if (count <= 0) + return Unset; + + if (allowUnset) + { + int cur = current == Unset ? count : (int)current; + int size = count + 1; + int next = Mod(cur + delta, size); + return next == count ? Unset : (uint)next; + } + + if (current == Unset) + return 0u; + return (uint)Mod((int)current + delta, count); + } + + private static int Mod(int value, int modulus) => + ((value % modulus) + modulus) % modulus; + + // ── Color swatches + shade scroll ─────────────────────────────────── + + private void SelectColor(int index) + { + if (_disposed) + return; + IRuntimeCharacterCreationView? view = _bindings.View(); + if (view is null) + return; + RuntimeCharacterCreationSnapshot snapshot = view.Snapshot; + if (!TryGetGender(view, snapshot, out ChargenGenderOptions? gender)) + return; + ChargenAppearanceSlot? slot = ColorSlotFor(_currentPart); + if (slot is null) + return; + + // gmCGAppearancePage::ListenToElementMessage's swatch cases each + // gate on the current part's own color-list length before calling + // SetColor — a swatch beyond the list clicks through to nothing. + int count = ColorCount(_currentPart, gender); + if (index >= count) + return; + + _bindings.SetAppearanceIndex?.Invoke(slot.Value, (uint)index); + } + + private void SetShadeFromScalar(float scalar) + { + if (_disposed) + return; + ChargenShadeSlot? slot = ShadeSlotFor(_currentPart); + if (slot is null) + return; + _bindings.SetShade?.Invoke(slot.Value, scalar); + } + + private void RefreshColorAndShadeControlsFromLatestSnapshot() + { + IRuntimeCharacterCreationView? view = _bindings.View(); + if (view is not null) + RefreshColorAndShadeControls(view, view.Snapshot); + } + + private void RefreshColorAndShadeControls( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + ChargenAppearanceSlot? colorSlot = ColorSlotFor(_currentPart); + uint currentColor = colorSlot is null ? Unset : ColorCurrent(_currentPart, snapshot.Appearance); + for (int i = 0; i < _swatches.Length; i++) + { + if (_swatches[i] is { } swatch) + swatch.Selected = colorSlot is not null && currentColor == (uint)i; + } + + ChargenShadeSlot? shadeSlot = ShadeSlotFor(_currentPart); + if (_shadeScroll is null) + return; + _shadeScroll.Enabled = shadeSlot is not null; + if (shadeSlot is { } slot) + { + double shade = ShadeCurrent(slot, snapshot.Appearance); + float scalar = shade < 0.0 ? 0f : (float)Math.Clamp(shade, 0.0, 1.0); + _shadeScroll.SetScalarPosition(scalar); + } + } + + // ── Spin labels ────────────────────────────────────────────────── + + private void RefreshSpins(ChargenGenderOptions gender, RuntimeCharacterCreationAppearance a) + { + SetStyleSpinLabel(Part.Hair, gender.HairStyles.Count, a.HairStyle); + SetStyleSpinLabel(Part.Eyes, gender.EyeStrips.Count, a.EyesStrip); + SetStyleSpinLabel(Part.Nose, gender.NoseStrips.Count, a.NoseStrip); + SetStyleSpinLabel(Part.Mouth, gender.MouthStrips.Count, a.MouthStrip); + SetGearSpinLabel(Part.Headgear, gender.Headgears, a.HeadgearStyle); + SetGearSpinLabel(Part.Shirt, gender.Shirts, a.ShirtStyle); + SetGearSpinLabel(Part.Trousers, gender.Pants, a.TrousersStyle); + SetGearSpinLabel(Part.Footwear, gender.Footwear, a.FootwearStyle); + } + + private void SetStyleSpinLabel(Part part, int count, uint index) + { + if (!_spins.TryGetValue(part, out UiButton? spin)) + return; + spin.Label = index != Unset && index < (uint)count + ? (index + 1).ToString(CultureInfo.InvariantCulture) + : "-"; + } + + private void SetGearSpinLabel(Part part, IReadOnlyList options, uint index) + { + if (!_spins.TryGetValue(part, out UiButton? spin)) + return; + spin.Label = index != Unset && index < (uint)options.Count + ? options[(int)index].Name + : "None"; + } + + // ── Preview rebuild ────────────────────────────────────────────── + + private void RebuildPreview( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot) + { + if (PreviewControl is null + || snapshot.HeritageId == 0u + || snapshot.GenderKey == 0u) + { + return; + } + + RuntimeCharacterCreationAppearance a = snapshot.Appearance; + var selection = new ChargenAppearanceSelection( + a.EyesStrip, a.NoseStrip, a.MouthStrip, + a.HairStyle, a.HairColor, a.EyeColor, + a.HeadgearStyle, a.HeadgearColor, + a.ShirtStyle, a.ShirtColor, + a.TrousersStyle, a.TrousersColor, + a.FootwearStyle, a.FootwearColor, + a.SkinShade, a.HairShade, a.HeadgearShade, + a.ShirtShade, a.TrousersShade, a.FootwearShade); + + PreviewControl.Rebuild(view.Options, snapshot.HeritageId, (int)snapshot.GenderKey, selection); + } + + // ── Per-part routing tables (retail SetColor @0x0047DD50 / SetShade @0x0047C860) ── + + private static ChargenAppearanceSlot? StyleSlotFor(Part part) => part switch + { + Part.Hair => ChargenAppearanceSlot.HairStyle, + Part.Eyes => ChargenAppearanceSlot.EyesStrip, + Part.Nose => ChargenAppearanceSlot.NoseStrip, + Part.Mouth => ChargenAppearanceSlot.MouthStrip, + Part.Headgear => ChargenAppearanceSlot.HeadgearStyle, + Part.Shirt => ChargenAppearanceSlot.ShirtStyle, + Part.Trousers => ChargenAppearanceSlot.TrousersStyle, + Part.Footwear => ChargenAppearanceSlot.FootwearStyle, + _ => null, // Skin. + }; + + /// Retail's SIX colorable parts (SetColor's cases + /// 0,1,5,6,7,8) — Nose/Mouth/Skin have no color list at all. + private static ChargenAppearanceSlot? ColorSlotFor(Part part) => part switch + { + Part.Hair => ChargenAppearanceSlot.HairColor, + Part.Eyes => ChargenAppearanceSlot.EyeColor, + Part.Headgear => ChargenAppearanceSlot.HeadgearColor, + Part.Shirt => ChargenAppearanceSlot.ShirtColor, + Part.Trousers => ChargenAppearanceSlot.TrousersColor, + Part.Footwear => ChargenAppearanceSlot.FootwearColor, + _ => null, + }; + + /// Retail's SetShade switch: Hair has its own shade; + /// Nose/Mouth/Skin ALL route to skin shade (cases 2/3/4 share one body + /// in the decompiled switch — a genuine retail quirk, not a porting + /// shortcut); Eyes has NO case at all (eye color has no shade + /// indirection anywhere in this campaign's model). + private static ChargenShadeSlot? ShadeSlotFor(Part part) => part switch + { + Part.Hair => ChargenShadeSlot.Hair, + Part.Nose => ChargenShadeSlot.Skin, + Part.Mouth => ChargenShadeSlot.Skin, + Part.Skin => ChargenShadeSlot.Skin, + Part.Headgear => ChargenShadeSlot.Headgear, + Part.Shirt => ChargenShadeSlot.Shirt, + Part.Trousers => ChargenShadeSlot.Trousers, + Part.Footwear => ChargenShadeSlot.Footwear, + _ => null, // Eyes. + }; + + private static int StyleCount(Part part, ChargenGenderOptions gender) => part switch + { + Part.Hair => gender.HairStyles.Count, + Part.Eyes => gender.EyeStrips.Count, + Part.Nose => gender.NoseStrips.Count, + Part.Mouth => gender.MouthStrips.Count, + Part.Headgear => gender.Headgears.Count, + Part.Shirt => gender.Shirts.Count, + Part.Trousers => gender.Pants.Count, + Part.Footwear => gender.Footwear.Count, + _ => 0, + }; + + /// Hair/Eyes have their own real per-gender color lists; + /// the four clothing slots share the gender's single + /// list (register + /// AP-208). + private static int ColorCount(Part part, ChargenGenderOptions gender) => part switch + { + Part.Hair => gender.HairColors.Count, + Part.Eyes => gender.EyeColors.Count, + Part.Headgear or Part.Shirt or Part.Trousers or Part.Footwear => + gender.ClothingColors.Count, + _ => 0, + }; + + private static uint StyleCurrent(Part part, RuntimeCharacterCreationAppearance a) => part switch + { + Part.Hair => a.HairStyle, + Part.Eyes => a.EyesStrip, + Part.Nose => a.NoseStrip, + Part.Mouth => a.MouthStrip, + Part.Headgear => a.HeadgearStyle, + Part.Shirt => a.ShirtStyle, + Part.Trousers => a.TrousersStyle, + Part.Footwear => a.FootwearStyle, + _ => Unset, + }; + + private static uint ColorCurrent(Part part, RuntimeCharacterCreationAppearance a) => part switch + { + Part.Hair => a.HairColor, + Part.Eyes => a.EyeColor, + Part.Headgear => a.HeadgearColor, + Part.Shirt => a.ShirtColor, + Part.Trousers => a.TrousersColor, + Part.Footwear => a.FootwearColor, + _ => Unset, + }; + + private static double ShadeCurrent(ChargenShadeSlot slot, RuntimeCharacterCreationAppearance a) => slot switch + { + ChargenShadeSlot.Skin => a.SkinShade, + ChargenShadeSlot.Hair => a.HairShade, + ChargenShadeSlot.Headgear => a.HeadgearShade, + ChargenShadeSlot.Shirt => a.ShirtShade, + ChargenShadeSlot.Trousers => a.TrousersShade, + ChargenShadeSlot.Footwear => a.FootwearShade, + _ => 0.0, + }; + + private static bool IsClothesHiddenHeritage(uint heritageId) => + heritageId == (uint)ChargenHeritageGroup.Gearknight + || heritageId == (uint)ChargenHeritageGroup.Olthoi + || heritageId == (uint)ChargenHeritageGroup.OlthoiAcid; + + private static bool TryGetGender( + IRuntimeCharacterCreationView view, + RuntimeCharacterCreationSnapshot snapshot, + [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out ChargenGenderOptions? gender) + { + gender = null; + if (snapshot.HeritageId == 0u || snapshot.GenderKey == 0u) + return false; + if (!view.Options.TryGetHeritage(snapshot.HeritageId, out ChargenHeritageOptions? heritage)) + return false; + return heritage.GendersByKey.TryGetValue((int)snapshot.GenderKey, out gender); + } + + private static T? Find(UiElement root, uint id) where T : UiElement => + UiElement.FindDescendant(root, id) as T; + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + if (_femaleButton is not null) _femaleButton.OnClick = null; + if (_maleButton is not null) _maleButton.OnClick = null; + if (_faceButton is not null) _faceButton.OnClick = null; + if (_clothesButton is not null) _clothesButton.OnClick = null; + foreach (UiButton spin in _spins.Values) + spin.OnClickAt = null; + _spins.Clear(); + foreach (UiButton? swatch in _swatches) + { + if (swatch is not null) + swatch.OnClick = null; + } + if (_shadeScroll is not null) + _shadeScroll.ScalarChanged = null; + if (_rotateClockwise is not null) _rotateClockwise.OnClick = null; + if (_rotateCounterClockwise is not null) _rotateCounterClockwise.OnClick = null; + if (_zoomIn is not null) _zoomIn.OnClick = null; + if (_zoomOut is not null) _zoomOut.OnClick = null; + // PreviewControl is owned by the composition root (disposed with + // the leased ChargenPreviewRenderer) — just drop the reference. + PreviewControl = null; + } +} diff --git a/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs b/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs index 659bf90e..26f7eb58 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs @@ -137,30 +137,29 @@ internal sealed class CharacterCreationHeritagePage : IDisposable Select(chosen); } + /// + /// Campaign CC slice CC6b-MOUNT: AD-101 RETIRED. The Appearance page's + /// real gender buttons (0x100003a7/0x100003a8) now exist, + /// so this no longer needs to auto-select a gender to keep the + /// Profession/Skills/Town pages usable — gender is a real player choice. + /// Retail's own default here is genuinely NOT blank: CharGenState:: + /// Reset @ 0x005C68A0 calls SetGender(this, 0) (unset), but + /// gmCharGenMainUI::gmCharGenMainUI @ 0x004e7eb0 calls + /// CharGenState::RandomizeCharacter (0x005c6d80) BEFORE any page + /// constructs — retail's chargen screen always opens with a fully + /// RANDOM heritage/gender/appearance/clothing/template/start-area + /// already rolled (see the ~0x004e81f5-0x004e8218 ctor call, ahead of + /// every page's own InitializePage). acdream does not port + /// RandomizeCharacter this round (register AP-214, the same + /// unported-primitive gap AP-212 already tracks for the Random button) + /// — so acdream's screen opens honestly blank instead, and gender is now + /// the player's first real choice on the Appearance page. + /// private void Select(uint heritageId) { if (_disposed) return; - 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); - } + _bindings.SelectHeritage(heritageId); } /// diff --git a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs index ab811feb..7e082c36 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationUiController.cs @@ -32,6 +32,11 @@ public sealed record CharacterCreationRuntimeBindings( Func SelectStartArea, Func Finish, Action RequestExit, + /// Campaign CC slice CC6b-MOUNT: the Appearance page's nine + /// spin controls and nine color swatches. + Func? SetAppearanceIndex = null, + /// CC6b-MOUNT: the Appearance page's shade scrollbar. + Func? SetShade = null, /// DAT string lookup (table 0x23000002, the SAME table /// every other ID_CharGen_*/ID_Character* key resolves /// through) — used by the Heritage page's composed description text. @@ -130,6 +135,7 @@ internal sealed class CharacterCreationUiController : IDisposable private readonly CharacterCreationProfessionPage _professionPage; private readonly CharacterCreationSkillsPage _skillsPage; private readonly CharacterCreationTownPage _townPage; + private readonly CharacterCreationAppearancePage _appearancePage; private Vector2 _authoredCanvas; private RuntimeGenerationToken _lastGeneration; @@ -218,6 +224,7 @@ internal sealed class CharacterCreationUiController : IDisposable _professionPage = new CharacterCreationProfessionPage(professionPageRoot, bindings); _skillsPage = new CharacterCreationSkillsPage(skillsPageRoot, bindings, templateResolver); _townPage = new CharacterCreationTownPage(townPageRoot, bindings); + _appearancePage = new CharacterCreationAppearancePage(appearancePageRoot, bindings); // gmCharGenMainUI::ListenToElementMessage @ 0x004e9450. _back.OnClick = OnBack; @@ -247,6 +254,30 @@ internal sealed class CharacterCreationUiController : IDisposable internal UiElement Root => _layout.Root; + /// The authored Appearance-page viewport (0x100003bb) — + /// CC6b-MOUNT's composition root assigns its Renderer once the + /// graphics backend exists (mirrors the paperdoll's own late + /// viewport.Renderer = ... assignment). + internal UiViewport? AppearanceViewport => _appearancePage.Viewport; + + /// CC6b-MOUNT: the late-bound zoom/rotate control surface — + /// see 's own + /// doc comment for why this is assigned after construction rather than + /// threaded through the ctor. + internal AcDream.App.Rendering.IChargenPreviewControl? AppearancePreviewControl + { + get => _appearancePage.PreviewControl; + set => _appearancePage.PreviewControl = value; + } + + /// Gates the Appearance preview's per-frame work on whether + /// that specific page — AND the whole chargen screen — is the one + /// currently showing. Close() only ever hides , + /// not the individual page roots, so a page-root-only check would stay + /// true after the screen closes on the Appearance page. Mirrors the + /// paperdoll's own outer-inventory-frame gate. + internal bool IsAppearancePageVisible => Root.Visible && _appearancePageRoot.Visible; + internal static CharacterCreationUiController? CreateDetached( UiRoot host, ImportedLayout layout, @@ -370,6 +401,7 @@ internal sealed class CharacterCreationUiController : IDisposable _professionPage.Refresh(view, snapshot); _skillsPage.Refresh(view, snapshot); _townPage.Refresh(view, snapshot); + _appearancePage.Refresh(view, snapshot); _lastGeneration = snapshot.Generation; _lastRevision = snapshot.Revision; } @@ -437,6 +469,7 @@ internal sealed class CharacterCreationUiController : IDisposable _professionPage.Dispose(); _skillsPage.Dispose(); _townPage.Dispose(); + _appearancePage.Dispose(); _host.RemoveChild(Root); } } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 4adbb823..145f168b 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -622,6 +622,31 @@ public sealed class RetailUiRuntime : IDisposable internal CharacterCreationUiController? CharacterCreationController => _characterCreationMount?.Controller; + /// Campaign CC slice CC6b-MOUNT: the Appearance page's authored + /// viewport (0x100003bb) — null until the screen has mounted. + /// Mirrors 's own computed-through + /// shape. + internal UiViewport? ChargenPreviewViewportWidget => + CharacterCreationController?.AppearanceViewport; + + /// CC6b-MOUNT: the late-bound zoom/rotate control surface the + /// composition root assigns once the graphics backend exists. + internal AcDream.App.Rendering.IChargenPreviewControl? ChargenPreviewControl + { + get => CharacterCreationController?.AppearancePreviewControl; + set + { + if (CharacterCreationController is { } controller) + controller.AppearancePreviewControl = value; + } + } + + /// CC6b-MOUNT: whether the Appearance page (specifically) is + /// the one currently showing — false, safely, before the screen mounts. + /// + internal bool IsChargenPreviewPageVisible => + CharacterCreationController?.IsAppearancePageVisible ?? false; + public static RetailUiRuntime Mount(RetailUiRuntimeBindings bindings) { ArgumentNullException.ThrowIfNull(bindings); diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewControllerTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewControllerTests.cs new file mode 100644 index 00000000..0e291b6f --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewControllerTests.cs @@ -0,0 +1,283 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.Content; +using AcDream.Content.CharGen; +using AcDream.Content.Vfx; +using AcDream.Core.CharGen; +using AcDream.Core.Physics.Motion; +using AcDream.Core.World; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.App.Tests.Rendering; + +/// +/// Campaign CC slice CC6b-MOUNT — installed-DAT gate for +/// 's rebuild/render logic, using fake +/// / +/// implementations (no live GPU device needed — mirrors +/// PaperdollFramePresenterTests's recording-fake pattern) against a +/// REAL dat-backed / +/// so ChargenAppearanceFactory.TryCompose and +/// ChargenPreviewEntityBuilder.TryBuildAnimated actually run. +/// +public sealed class ChargenPreviewControllerTests +{ + private readonly ITestOutputHelper _out; + public ChargenPreviewControllerTests(ITestOutputHelper output) => _out = output; + + private const uint AluvianId = 1u; + private const uint GearknightId = 6u; + + [Fact] + public void Rebuild_SameSelectionTwice_IsANoOpSecondTime() + { + if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) + return; + using (dats) + using (adapter) + { + (ChargenOptions options, ChargenAppearanceCatalog catalog) = LoadFixture(adapter!); + var renderer = new FakeChargenRenderer(); + var view = new FakeChargenView(); + var controller = new ChargenPreviewController( + renderer, new ChargenPreviewCamera(), view, + adapter!, new RetailAnimationLoader(adapter!), catalog, catalog, new object()); + + ChargenAppearanceSelection selection = DefaultSelection(options, AluvianId, 1); + Assert.True(controller.Rebuild(options, AluvianId, 1, selection)); + Assert.True(controller.Rebuild(options, AluvianId, 1, selection)); + + Assert.Equal(1, renderer.SetPreviewCallCount); + } + } + + [Fact] + public void Rebuild_HeritageChange_ResetsCameraToTheNewHeritagesDefaultEye() + { + if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) + return; + using (dats) + using (adapter) + { + (ChargenOptions options, ChargenAppearanceCatalog catalog) = LoadFixture(adapter!); + var renderer = new FakeChargenRenderer(); + var view = new FakeChargenView(); + var camera = new ChargenPreviewCamera(); + var controller = new ChargenPreviewController( + renderer, camera, view, + adapter!, new RetailAnimationLoader(adapter!), catalog, catalog, new object()); + + Assert.True(controller.Rebuild( + options, AluvianId, 1, DefaultSelection(options, AluvianId, 1))); + // Poke the SAME camera instance the controller shares (this is + // exactly the injection seam ChargenPreviewController's own doc + // comment describes) — simulates the camera having drifted away + // from the heritage default (e.g. mid zoom-out tween). + camera.Eye = new Vector3(0f, -99f, 99f); + + if (!options.TryGetHeritage(GearknightId, out ChargenHeritageOptions? gearknight) + || gearknight!.GendersByKey.Count == 0) + { + _out.WriteLine("SKIP: installed dat has no Gearknight gender to switch to."); + return; + } + int gearknightGender = gearknight.GendersByKey.Keys.First(); + Assert.True(controller.Rebuild( + options, GearknightId, gearknightGender, + DefaultSelection(options, GearknightId, gearknightGender))); + + Assert.Equal(ChargenPreviewCamera.ResolveDefaultEye(GearknightId), controller.CameraEye); + } + } + + /// + /// Decomp-cited (gmCGAppearancePage::Update's two confirmed direct + /// call sites — InitializePage and the gender-button handlers — + /// vs the narrower SetSelection/SetColor/SetShade + /// every spin/color/shade change goes through instead): a rebuild that + /// changes ONLY the appearance selection (same heritage, same gender) + /// must NOT snap the camera back to the heritage default. + /// + [Fact] + public void Rebuild_AppearanceOnlyChange_LeavesTheCameraUntouched() + { + if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) + return; + using (dats) + using (adapter) + { + (ChargenOptions options, ChargenAppearanceCatalog catalog) = LoadFixture(adapter!); + var renderer = new FakeChargenRenderer(); + var view = new FakeChargenView(); + var camera = new ChargenPreviewCamera(); + var controller = new ChargenPreviewController( + renderer, camera, view, + adapter!, new RetailAnimationLoader(adapter!), catalog, catalog, new object()); + + ChargenAppearanceSelection first = DefaultSelection(options, AluvianId, 1); + Assert.True(controller.Rebuild(options, AluvianId, 1, first)); + var pokedEye = new Vector3(0f, -99f, 99f); + camera.Eye = pokedEye; + + ChargenAppearanceSelection second = first with { SkinShade = 0.9 }; + Assert.True(controller.Rebuild(options, AluvianId, 1, second)); + + Assert.Equal(pokedEye, controller.CameraEye); + } + } + + [Fact] + public void Rebuild_PreservesZoomState_AcrossAnAppearanceOnlyChange() + { + if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) + return; + using (dats) + using (adapter) + { + (ChargenOptions options, ChargenAppearanceCatalog catalog) = LoadFixture(adapter!); + var renderer = new FakeChargenRenderer(); + var view = new FakeChargenView(); + var controller = new ChargenPreviewController( + renderer, new ChargenPreviewCamera(), view, + adapter!, new RetailAnimationLoader(adapter!), catalog, catalog, new object()); + + ChargenAppearanceSelection first = DefaultSelection(options, AluvianId, 1); + Assert.True(controller.Rebuild(options, AluvianId, 1, first)); + controller.ZoomIn(); + Assert.True(controller.IsZoomedIn); + + // A DIFFERENT selection, same heritage/gender — retail's + // gmCGAppearancePage::Update only resets the camera POSITION on + // heritage/gender change; m_bZoomedIn is untouched by spin/color/ + // shade edits. + ChargenAppearanceSelection second = first with { SkinShade = 0.9 }; + Assert.True(controller.Rebuild(options, AluvianId, 1, second)); + + Assert.True(controller.IsZoomedIn); + } + } + + [Fact] + public void Rebuild_ThenRender_SeedsTheEntityHeadingToTheRetailDefault180Degrees() + { + if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) + return; + using (dats) + using (adapter) + { + (ChargenOptions options, ChargenAppearanceCatalog catalog) = LoadFixture(adapter!); + var renderer = new FakeChargenRenderer(); + var view = new FakeChargenView(); + var controller = new ChargenPreviewController( + renderer, new ChargenPreviewCamera(), view, + adapter!, new RetailAnimationLoader(adapter!), catalog, catalog, new object()); + + Assert.True(controller.Rebuild( + options, AluvianId, 1, DefaultSelection(options, AluvianId, 1))); + Assert.NotNull(renderer.LastEntity); + + controller.Render(); + + Quaternion expected = MoveToMath.SetHeading( + Quaternion.Identity, ChargenPreviewRotationController.RetailDefaultHeadingDegrees); + Quaternion actual = renderer.LastEntity!.Rotation; + Assert.Equal(expected.X, actual.X, 4); + Assert.Equal(expected.Y, actual.Y, 4); + Assert.Equal(expected.Z, actual.Z, 4); + Assert.Equal(expected.W, actual.W, 4); + } + } + + [Fact] + public void Render_WhilePageInvisible_SkipsRenderAndTexturePublication() + { + if (!TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter)) + return; + using (dats) + using (adapter) + { + (ChargenOptions options, ChargenAppearanceCatalog catalog) = LoadFixture(adapter!); + var renderer = new FakeChargenRenderer(); + var view = new FakeChargenView { Visible = false }; + var controller = new ChargenPreviewController( + renderer, new ChargenPreviewCamera(), view, + adapter!, new RetailAnimationLoader(adapter!), catalog, catalog, new object()); + Assert.True(controller.Rebuild( + options, AluvianId, 1, DefaultSelection(options, AluvianId, 1))); + + controller.Render(); + + Assert.Equal(0, renderer.RenderCallCount); + Assert.Null(view.LastTextureHandle); + } + } + + private static ChargenAppearanceSelection DefaultSelection( + ChargenOptions options, uint heritageId, int genderKey) + { + Assert.True(options.TryGetHeritage(heritageId, out ChargenHeritageOptions? heritage)); + Assert.True(heritage!.GendersByKey.TryGetValue(genderKey, out ChargenGenderOptions? gender)); + return ChargenAppearanceSelection.Default with + { + HairStyle = gender!.HairStyles.Count > 0 ? 0u : ChargenAppearanceSelection.Unset, + SkinShade = 0.5, + }; + } + + private static (ChargenOptions, ChargenAppearanceCatalog) LoadFixture(IDatReaderWriter dats) => + (ChargenTableReader.Load(dats), new ChargenAppearanceCatalog(dats)); + + private bool TryOpen(out DatCollection? dats, out DatCollectionAdapter? adapter) + { + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) + { + _out.WriteLine("SKIP: dats unavailable"); + dats = null; + adapter = null; + return false; + } + dats = new DatCollection(datDir, DatAccessType.Read); + adapter = new DatCollectionAdapter(dats); + return true; + } + + private sealed class FakeChargenRenderer : IChargenPreviewRenderer + { + public WorldEntity? LastEntity { get; private set; } + public int SetPreviewCallCount { get; private set; } + public int RenderCallCount { get; private set; } + + public void SetPreview(WorldEntity? entity) + { + LastEntity = entity; + SetPreviewCallCount++; + } + + public uint Render(int width, int height) + { + RenderCallCount++; + return 42u; + } + } + + private sealed class FakeChargenView : IChargenPreviewFrameView + { + public bool Visible { get; set; } = true; + public int Width { get; set; } = 128; + public int Height { get; set; } = 128; + public uint? LastTextureHandle { get; private set; } + + public bool TryGetVisibleSize(out int width, out int height) + { + width = Width; + height = Height; + return Visible; + } + + public void SetTextureHandle(uint textureHandle) => LastTextureHandle = textureHandle; + } +} diff --git a/tests/AcDream.App.Tests/Rendering/ChargenPreviewRotationControllerTests.cs b/tests/AcDream.App.Tests/Rendering/ChargenPreviewRotationControllerTests.cs index 9cd6158e..56ad43b5 100644 --- a/tests/AcDream.App.Tests/Rendering/ChargenPreviewRotationControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ChargenPreviewRotationControllerTests.cs @@ -11,10 +11,32 @@ namespace AcDream.App.Tests.Rendering; /// public sealed class ChargenPreviewRotationControllerTests { + /// + /// CC6b-MOUNT: retail's OPERATIVE starting heading is 180, not the ctor's + /// raw 0 — gmCGAppearancePage::gmCGAppearancePage @0x0047CDAC sets + /// m_fCurHeading = 0f, but InitializePage @0x0047FDD0 always + /// runs immediately afterward (before the page is ever visible) and + /// overrides it to 180f at 0x00480235, pushed via + /// SetPlayerHeading at 0x0048023F. No player-visible chargen + /// Appearance frame is ever rendered at 0°. This is the seam a real mount + /// site experiences (the parameterless constructor), pinned here so a + /// future consumer can't silently regress to facing the character away + /// from the camera. See + /// for the full citation, including the cross-confirming + /// gmCGSummaryPage/gmBarberUI sibling call sites. + /// + [Fact] + public void DefaultConstructor_StartsAtRetailsOperative180DegreeHeading() + { + var controller = new ChargenPreviewRotationController(); + + Assert.Equal(180f, controller.HeadingDegrees); + } + [Fact] public void Toggle_StartsRotatingInTheGivenDirection() { - var controller = new ChargenPreviewRotationController(); + var controller = new ChargenPreviewRotationController(0f); controller.Toggle(ChargenRotateDirection.Clockwise); Assert.True(controller.IsRotating); @@ -24,7 +46,7 @@ public sealed class ChargenPreviewRotationControllerTests [Fact] public void Toggle_SameDirectionWhileRotating_Stops() { - var controller = new ChargenPreviewRotationController(); + var controller = new ChargenPreviewRotationController(0f); controller.Toggle(ChargenRotateDirection.Clockwise); controller.Toggle(ChargenRotateDirection.Clockwise); @@ -34,7 +56,7 @@ public sealed class ChargenPreviewRotationControllerTests [Fact] public void Toggle_OppositeDirectionWhileRotating_SwitchesDirectionAndKeepsRotating() { - var controller = new ChargenPreviewRotationController(); + var controller = new ChargenPreviewRotationController(0f); controller.Toggle(ChargenRotateDirection.Clockwise); controller.Toggle(ChargenRotateDirection.CounterClockwise); @@ -45,7 +67,7 @@ public sealed class ChargenPreviewRotationControllerTests [Fact] public void Tick_WhileNotRotating_IsANoOp() { - var controller = new ChargenPreviewRotationController(); + var controller = new ChargenPreviewRotationController(0f); controller.Tick(100.0); Assert.Equal(0f, controller.HeadingDegrees); @@ -57,7 +79,7 @@ public sealed class ChargenPreviewRotationControllerTests // Rotate() invalidates m_dLastRotateTime so the very first DoRotation // tick resets it to "now" rather than computing a huge jump from a // stale/never-set timestamp. - var controller = new ChargenPreviewRotationController(); + var controller = new ChargenPreviewRotationController(0f); controller.Toggle(ChargenRotateDirection.Clockwise); controller.Tick(1000.0); @@ -71,8 +93,10 @@ public sealed class ChargenPreviewRotationControllerTests // Seed "now" nonzero (0.0 collides with the <= 0 reset-if-invalid // guard, same as retail's own sentinel check would if Timer::cur_time // could ever read exactly zero — never in practice, so tests avoid - // it too). - var controller = new ChargenPreviewRotationController(); + // it too). Explicit 0f baseline keeps the relative-delta assertion + // below simple; the retail-default seam has its own dedicated test + // above. + var controller = new ChargenPreviewRotationController(0f); controller.Toggle(ChargenRotateDirection.Clockwise); controller.Tick(10.0); // seeds lastRotateTime = 10, zero delta. controller.Tick(11.5); // half a revolution at 3 s/rev. @@ -83,7 +107,7 @@ public sealed class ChargenPreviewRotationControllerTests [Fact] public void Tick_CounterClockwiseAdvance_SubtractsAndWrapsPositive() { - var controller = new ChargenPreviewRotationController(); + var controller = new ChargenPreviewRotationController(0f); controller.Toggle(ChargenRotateDirection.CounterClockwise); controller.Tick(10.0); controller.Tick(11.5); // would go to -180, wraps to +180. @@ -94,7 +118,7 @@ public sealed class ChargenPreviewRotationControllerTests [Fact] public void Tick_AccumulatesAcrossMultipleTicks() { - var controller = new ChargenPreviewRotationController(); + var controller = new ChargenPreviewRotationController(0f); controller.Toggle(ChargenRotateDirection.Clockwise); controller.Tick(10.0); controller.Tick(10.5); // +60 deg. @@ -113,7 +137,7 @@ public sealed class ChargenPreviewRotationControllerTests [Fact] public void Tick_ClockwiseAdvancePast360_ClampsBackBySubtracting360() { - var controller = new ChargenPreviewRotationController(); + var controller = new ChargenPreviewRotationController(0f); controller.Toggle(ChargenRotateDirection.Clockwise); controller.Tick(10.0); // seeds lastRotateTime = 10, zero delta. controller.Tick(10.0 + 3.5); // 3.5s at 3s/rev = 420 deg -> 420, clamped to 60. @@ -124,7 +148,7 @@ public sealed class ChargenPreviewRotationControllerTests [Fact] public void ToOrientation_AtZeroHeading_IsIdentity() { - var controller = new ChargenPreviewRotationController(); + var controller = new ChargenPreviewRotationController(0f); Quaternion orientation = controller.ToOrientation(); Assert.Equal(Quaternion.Identity.X, orientation.X, 4); diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs index 9fd47f01..6fb39999 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Linq; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Content; @@ -314,6 +315,131 @@ public sealed class CharacterCreationLiveDatTests } } + /// + /// Campaign CC slice CC6b-MOUNT — the Appearance page's full authored + /// widget catalog. Pins the campaign plan's risk item 4 finding (the + /// color-wheel/gradient family resolves through EXISTING + /// DatWidgetFactory mappings; no new widget type was needed — + /// see 's own class doc) + /// against the real installed DAT: gender/Face/Clothes buttons, all + /// nine spins, all nine color swatches, the shade scrollbar, and the + /// viewport. + /// + [InstalledDatFact] + public void AppearancePage_HasGenderChoiceSpinsSwatchesShadeAndViewport() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + + UiElement appearanceRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.AppearancePageElementId)); + + AssertButton(appearanceRoot, CharacterCreationAppearancePage.FemaleButtonId); + AssertButton(appearanceRoot, CharacterCreationAppearancePage.MaleButtonId); + AssertButton(appearanceRoot, CharacterCreationAppearancePage.FaceButtonId); + AssertButton(appearanceRoot, CharacterCreationAppearancePage.ClothesButtonId); + Assert.IsAssignableFrom( + UiElement.FindDescendant(appearanceRoot, CharacterCreationAppearancePage.FaceChoicesId)); + Assert.IsAssignableFrom( + UiElement.FindDescendant(appearanceRoot, CharacterCreationAppearancePage.ClothesChoicesId)); + + foreach (uint spinId in new[] + { + CharacterCreationAppearancePage.HairSpinId, + CharacterCreationAppearancePage.EyesSpinId, + CharacterCreationAppearancePage.NoseSpinId, + CharacterCreationAppearancePage.MouthSpinId, + CharacterCreationAppearancePage.SkinSpinId, + CharacterCreationAppearancePage.HeadgearSpinId, + CharacterCreationAppearancePage.ShirtSpinId, + CharacterCreationAppearancePage.TrousersSpinId, + CharacterCreationAppearancePage.FootwearSpinId, + }) + { + AssertButton(appearanceRoot, spinId); + } + + // Every color-wheel-family id resolves through EXISTING + // DatWidgetFactory mappings (Button=1, Scrollbar=0xB, the generic + // Type-3 fallback) — the risk-item-4 scouting result, pinned. + foreach (uint swatchId in CharacterCreationAppearancePage.SwatchIds) + AssertButton(appearanceRoot, swatchId); + Assert.IsType( + UiElement.FindDescendant(appearanceRoot, CharacterCreationAppearancePage.ShadeScrollId)); + Assert.IsType( + UiElement.FindDescendant(appearanceRoot, CharacterCreationAppearancePage.GradCircleId)); + + AssertButton(appearanceRoot, CharacterCreationAppearancePage.RotateClockwiseId); + AssertButton(appearanceRoot, CharacterCreationAppearancePage.RotateCounterClockwiseId); + AssertButton(appearanceRoot, CharacterCreationAppearancePage.ZoomInId); + AssertButton(appearanceRoot, CharacterCreationAppearancePage.ZoomOutId); + + Assert.IsType( + UiElement.FindDescendant(appearanceRoot, CharacterCreationAppearancePage.ViewportId)); + } + + /// + /// Live-DAT-measured arrow geometry the page's spin OnClickAt zones are + /// built from — every one of the nine spins is uniformly 200px wide + /// with the two locally-reused arrow child ids + /// (0x1000030a decrement / 0x1000030b increment) at the + /// SAME local positions. If a future DAT revision changes this shared + /// template's geometry, this test (not a silent behavior change) is + /// where it shows up. + /// + [InstalledDatFact] + public void AppearancePage_SpinArrowGeometryIsUniformAcrossAllNineSpins() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ElementInfo rootInfo = Assert.IsType( + LayoutImporter.ImportInfos( + dats, layoutId, CharacterCreationUiController.RootElementId)); + ElementInfo? appearanceInfo = FindInfo( + rootInfo, CharacterCreationUiController.AppearancePageElementId); + Assert.NotNull(appearanceInfo); + + foreach (uint spinId in new[] + { + CharacterCreationAppearancePage.HairSpinId, + CharacterCreationAppearancePage.EyesSpinId, + CharacterCreationAppearancePage.NoseSpinId, + CharacterCreationAppearancePage.MouthSpinId, + CharacterCreationAppearancePage.SkinSpinId, + CharacterCreationAppearancePage.HeadgearSpinId, + CharacterCreationAppearancePage.ShirtSpinId, + CharacterCreationAppearancePage.TrousersSpinId, + CharacterCreationAppearancePage.FootwearSpinId, + }) + { + ElementInfo? spin = FindInfo(appearanceInfo!, spinId); + Assert.NotNull(spin); + Assert.Equal(200f, spin!.Width); + + ElementInfo? decrement = spin.Children.FirstOrDefault(c => c.Id == 0x1000030Au); + ElementInfo? increment = spin.Children.FirstOrDefault(c => c.Id == 0x1000030Bu); + Assert.NotNull(decrement); + Assert.NotNull(increment); + Assert.Equal(80f, decrement!.X); + Assert.Equal(127f, increment!.X); + } + } + + private static ElementInfo? FindInfo(ElementInfo node, uint id) + { + if (node.Id == id) return node; + foreach (ElementInfo child in node.Children) + { + ElementInfo? found = FindInfo(child, id); + if (found is not null) return found; + } + return null; + } + private static RetailDialogFactory MakeDialogFactory(IDatReaderWriter dats, UiRoot host) { uint dialogDid = RetailDataIdResolver.Resolve(dats, 2u, 5u); @@ -331,6 +457,9 @@ public sealed class CharacterCreationLiveDatTests private static void AssertButton(ImportedLayout layout, uint elementId) => Assert.IsType(layout.FindElement(elementId)); + private static void AssertButton(UiElement root, uint elementId) => + Assert.IsType(UiElement.FindDescendant(root, elementId)); + private static ImportedLayout BuildSelected( IDatReaderWriter dats, uint layoutDid, diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs index 2f2c201f..6f797a4a 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs @@ -167,10 +167,12 @@ public sealed class CharacterCreationUiControllerTests } /// gmCGHeritagePage::ListenToElementMessage @ 0x00483860's - /// per-button SetHeritageGroup literal, plus CC4's interim - /// auto-gender-select seam (register AD-100). + /// per-button SetHeritageGroup literal. AD-101 RETIRED at CC6b-MOUNT: + /// a heritage click no longer auto-selects a gender — the Appearance + /// page's real gender buttons are the only gender-selection path now + /// (see ). [Fact] - public void HeritageButton_SelectsHeritage_AndAutoSelectsFirstGender() + public void HeritageButton_SelectsHeritage_WithNoGenderSideEffect() { using var environment = new EnvironmentHarness(); environment.Controller.Open(); @@ -178,7 +180,7 @@ public sealed class CharacterCreationUiControllerTests environment.Button(0x100003BFu).OnClick!(); // Aluvian Assert.Equal(AluvianId, environment.Runtime.LastSelectedHeritage); - Assert.Equal(GenderKey, environment.Runtime.LastSelectedGender); + Assert.Equal(0u, environment.Runtime.LastSelectedGender); } /// gmCharGenMainUI::SetProgressState @ 0x004e7a10's Olthoi @@ -491,6 +493,251 @@ public sealed class CharacterCreationUiControllerTests Assert.Null(environment.Host.FixedCanvasSize); } + // ── Campaign CC slice CC6b-MOUNT: Appearance page ─────────────────── + + [Fact] + public void AppearanceGenderButton_SelectsGender() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!(); + + environment.Button(CharacterCreationAppearancePage.MaleButtonId).OnClick!(); + Assert.Equal(1u, environment.Runtime.LastSelectedGender); + + environment.Button(CharacterCreationAppearancePage.FemaleButtonId).OnClick!(); + Assert.Equal(2u, environment.Runtime.LastSelectedGender); + } + + /// Spin arrow geometry (live-DAT-measured, see + /// 's own class doc): + /// x=[80,127) is the decrement child, x=[127,174) is the increment + /// child. + [Fact] + public void AppearanceSpin_IncrementZoneClick_CyclesStyleForwardFromUnset() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + SelectAluvianMale(environment); + + environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(150, 10); + + Assert.Equal(ChargenAppearanceSlot.HairStyle, environment.Runtime.LastAppearanceSlot); + Assert.Equal(0u, environment.Runtime.LastAppearanceIndex); + } + + [Fact] + public void AppearanceSpin_DecrementZoneClick_FromUnset_StartsAtStyleZero() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + SelectAluvianMale(environment); + + // Non-headgear spins have no decomp-observable Unset-cycling case + // (retail always has a real 0-based index by the time the user can + // click — CycleIndex's own citation) — a first click from Unset in + // EITHER direction just starts cycling at style 0, not a ring. + environment.Button(CharacterCreationAppearancePage.HairSpinId).OnClickAt!(100, 10); + + Assert.Equal(ChargenAppearanceSlot.HairStyle, environment.Runtime.LastAppearanceSlot); + Assert.Equal(0u, environment.Runtime.LastAppearanceIndex); + } + + /// CharGenState::SetHeadgearStyle's decomp-derived + /// (count+1)-position ring: incrementing from Unset lands on style 0; + /// decrementing FROM style 0 lands back on Unset. Headgear is the ONLY + /// spin with this ring — see 's + /// own citation. + [Fact] + public void AppearanceHeadgearSpin_RingIncludesTheUnsetPosition() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + SelectAluvianMale(environment); + environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!(); + environment.Button(CharacterCreationAppearancePage.ClothesButtonId).OnClick!(); + + environment.Button(CharacterCreationAppearancePage.HeadgearSpinId).OnClickAt!(150, 10); // increment + Assert.Equal(ChargenAppearanceSlot.HeadgearStyle, environment.Runtime.LastAppearanceSlot); + Assert.Equal(0u, environment.Runtime.LastAppearanceIndex); + + environment.Button(CharacterCreationAppearancePage.HeadgearSpinId).OnClickAt!(100, 10); // decrement + Assert.Equal(RuntimeCharacterCreationAppearance.Unset, environment.Runtime.LastAppearanceIndex); + } + + /// Pure wrap-semantics unit tests for + /// — the + /// decomp-derived arithmetic every spin's OnClickAt zone drives. + /// + [Theory] + [InlineData(0u, +1, 3, false, 1u)] + [InlineData(2u, +1, 3, false, 0u)] // plain wrap forward past the end. + [InlineData(1u, -1, 3, false, 0u)] + [InlineData(0u, -1, 3, false, 2u)] // plain wrap backward past the start. + [InlineData(RuntimeCharacterCreationAppearance.Unset, +1, 3, false, 0u)] + [InlineData(RuntimeCharacterCreationAppearance.Unset, -1, 3, false, 0u)] + [InlineData(0u, -1, 3, true, RuntimeCharacterCreationAppearance.Unset)] // headgear ring: 0 -> Unset. + [InlineData(RuntimeCharacterCreationAppearance.Unset, +1, 3, true, 0u)] // headgear ring: Unset -> 0. + [InlineData(2u, +1, 3, true, RuntimeCharacterCreationAppearance.Unset)] // headgear ring: last -> Unset. + [InlineData(RuntimeCharacterCreationAppearance.Unset, -1, 3, true, 2u)] // headgear ring: Unset -> last. + public void CycleIndex_MatchesRetailsDecompDerivedWrap( + uint current, int delta, int count, bool allowUnset, uint expected) + { + Assert.Equal( + expected, + CharacterCreationAppearancePage.CycleIndex(current, delta, count, allowUnset)); + } + + /// Skin has no style index — retail disables its arrow + /// children (SetAttribute_Bool(...,0xd,1)). Every click on the skin + /// spin only selects it as the current part. + [Fact] + public void AppearanceSkinSpin_Click_NeverCallsSetAppearanceIndex() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + SelectAluvianMale(environment); + + environment.Button(CharacterCreationAppearancePage.SkinSpinId).OnClickAt!(100, 10); + environment.Button(CharacterCreationAppearancePage.SkinSpinId).OnClickAt!(150, 10); + + Assert.Equal(0, environment.Runtime.AppearanceIndexCallCount); + } + + /// gmCGAppearancePage::Update's heritage 6/0xc/0xd gate: the + /// Clothes sub-tab and the Nose/Mouth spins all hide. + [Fact] + public void OlthoiHeritage_HidesClothesButtonAndNoseMouthSpins() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(OlthoiId); + environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!(); + + Assert.False(environment.Button(CharacterCreationAppearancePage.ClothesButtonId).Visible); + Assert.False(environment.Button(CharacterCreationAppearancePage.NoseSpinId).Visible); + Assert.False(environment.Button(CharacterCreationAppearancePage.MouthSpinId).Visible); + } + + /// Fixture's Hair color list has 3 entries (indices 0-2) — a + /// swatch beyond that never reaches SetAppearanceIndex, matching + /// retail's own iNumColors > N gate. + [Fact] + public void AppearanceSwatch_WithinColorCount_SetsColorForTheCurrentPart() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + SelectAluvianMale(environment); + + // Part defaults to Hair on construction — no extra click needed. + environment.Button(CharacterCreationAppearancePage.SwatchIds[1]).OnClick!(); + + Assert.Equal(ChargenAppearanceSlot.HairColor, environment.Runtime.LastAppearanceSlot); + Assert.Equal(1u, environment.Runtime.LastAppearanceIndex); + } + + [Fact] + public void AppearanceSwatch_BeyondColorCount_IsANoOp() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + SelectAluvianMale(environment); + + environment.Button(CharacterCreationAppearancePage.SwatchIds[^1]).OnClick!(); // index 8, count 3. + + Assert.Equal(0, environment.Runtime.AppearanceIndexCallCount); + } + + [Fact] + public void AppearanceShadeScroll_ScalarChanged_SetsShadeForTheCurrentPart() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + SelectAluvianMale(environment); + + environment.ShadeScroll().ScalarChanged!(0.75f); + + Assert.Equal(ChargenShadeSlot.Hair, environment.Runtime.LastShadeSlot); + Assert.Equal(0.75, environment.Runtime.LastShadeValue, 3); + } + + /// Nose/Mouth/Skin all route the shade scroll to SKIN shade — + /// SetShade's cases 2/3/4 share one body in the decompiled switch. + /// + [Fact] + public void AppearanceShadeScroll_ForNoseOrMouth_RoutesToSkinShade() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + SelectAluvianMale(environment); + // Select Nose as the current part: click its body (outside the + // arrow zones), which never changes the style index. + environment.Button(CharacterCreationAppearancePage.NoseSpinId).OnClickAt!(10, 10); + + environment.ShadeScroll().ScalarChanged!(0.5f); + + Assert.Equal(ChargenShadeSlot.Skin, environment.Runtime.LastShadeSlot); + } + + [Fact] + public void AppearanceZoomAndRotateButtons_DelegateToThePreviewControl() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + var preview = new FakeChargenPreviewControl(); + environment.Controller.AppearancePreviewControl = preview; + + environment.Button(CharacterCreationAppearancePage.ZoomInId).OnClick!(); + environment.Button(CharacterCreationAppearancePage.ZoomOutId).OnClick!(); + environment.Button(CharacterCreationAppearancePage.RotateClockwiseId).OnClick!(); + environment.Button(CharacterCreationAppearancePage.RotateCounterClockwiseId).OnClick!(); + + Assert.Equal(1, preview.ZoomInCalls); + Assert.Equal(1, preview.ZoomOutCalls); + Assert.Equal(1, preview.RotateClockwiseCalls); + Assert.Equal(1, preview.RotateCounterClockwiseCalls); + } + + [Fact] + public void AppearanceZoomButtons_WithNoPreviewControlAssignedYet_AreHarmlessNoOps() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + + // The page mounts before the graphics backend exists — every zoom/ + // rotate click before LivePresentationComposition assigns a real + // control must be a silent no-op, not a NullReferenceException. + environment.Button(CharacterCreationAppearancePage.ZoomInId).OnClick!(); + environment.Button(CharacterCreationAppearancePage.RotateClockwiseId).OnClick!(); + } + + private static void SelectAluvianMale(EnvironmentHarness environment) + { + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!(); + environment.Button(CharacterCreationAppearancePage.MaleButtonId).OnClick!(); + } + + private sealed class FakeChargenPreviewControl : AcDream.App.Rendering.IChargenPreviewControl + { + public int ZoomInCalls { get; private set; } + public int ZoomOutCalls { get; private set; } + public int RotateClockwiseCalls { get; private set; } + public int RotateCounterClockwiseCalls { get; private set; } + + public bool Rebuild( + ChargenOptions options, + uint heritageId, + int genderKey, + ChargenAppearanceSelection selection) => true; + + public void ZoomIn() => ZoomInCalls++; + public void ZoomOut() => ZoomOutCalls++; + public void RotateClockwise() => RotateClockwiseCalls++; + public void RotateCounterClockwise() => RotateCounterClockwiseCalls++; + } + private static void BumpRevisionAndTick(EnvironmentHarness environment) { RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot; @@ -552,6 +799,9 @@ public sealed class CharacterCreationUiControllerTests public UiTemplateListBox SkillsList() => Assert.IsType(Screen.FindElement(0x100003F7u)); + public UiScrollbar ShadeScroll() => + Assert.IsType(Screen.FindElement(CharacterCreationAppearancePage.ShadeScrollId)); + /// Confirms or cancels the MOST RECENTLY opened confirmation /// dialog, using 's real /// button ids off the layout the factory's createLayout @@ -599,6 +849,8 @@ public sealed class CharacterCreationUiControllerTests SelectStartArea, _ => Result(RuntimeCommandStatus.Accepted), () => RequestExitCalls++, + SetAppearanceIndex: SetAppearanceIndex, + SetShade: SetShade, ResolveText: _ => null, OpenOnStart: false); } @@ -613,6 +865,11 @@ public sealed class CharacterCreationUiControllerTests public ChargenAttributeId LastAttributeSet { get; private set; } public int LastAttributeValue { get; private set; } public int LastSelectedStartArea { get; private set; } = -1; + public ChargenAppearanceSlot? LastAppearanceSlot { get; private set; } + public uint LastAppearanceIndex { get; private set; } + public int AppearanceIndexCallCount { get; private set; } + public ChargenShadeSlot? LastShadeSlot { get; private set; } + public double LastShadeValue { get; private set; } public void SelectHeritageDirect(uint heritageId) => SelectHeritage(heritageId); @@ -662,6 +919,44 @@ public sealed class CharacterCreationUiControllerTests return Result(RuntimeCommandStatus.Accepted); } + private RuntimeCommandResult SetAppearanceIndex(ChargenAppearanceSlot slot, uint index) + { + LastAppearanceSlot = slot; + LastAppearanceIndex = index; + AppearanceIndexCallCount++; + View.Snapshot = View.Snapshot with { Appearance = WithAppearanceIndex(View.Snapshot.Appearance, slot, index) }; + return Result(RuntimeCommandStatus.Accepted); + } + + private RuntimeCommandResult SetShade(ChargenShadeSlot slot, double value) + { + LastShadeSlot = slot; + LastShadeValue = value; + return Result(RuntimeCommandStatus.Accepted); + } + + private static RuntimeCharacterCreationAppearance WithAppearanceIndex( + RuntimeCharacterCreationAppearance a, + ChargenAppearanceSlot slot, + uint index) => slot switch + { + ChargenAppearanceSlot.EyesStrip => a with { EyesStrip = index }, + ChargenAppearanceSlot.NoseStrip => a with { NoseStrip = index }, + ChargenAppearanceSlot.MouthStrip => a with { MouthStrip = index }, + ChargenAppearanceSlot.HairStyle => a with { HairStyle = index }, + ChargenAppearanceSlot.HairColor => a with { HairColor = index }, + ChargenAppearanceSlot.EyeColor => a with { EyeColor = index }, + ChargenAppearanceSlot.HeadgearStyle => a with { HeadgearStyle = index }, + ChargenAppearanceSlot.HeadgearColor => a with { HeadgearColor = index }, + ChargenAppearanceSlot.ShirtStyle => a with { ShirtStyle = index }, + ChargenAppearanceSlot.ShirtColor => a with { ShirtColor = index }, + ChargenAppearanceSlot.TrousersStyle => a with { TrousersStyle = index }, + ChargenAppearanceSlot.TrousersColor => a with { TrousersColor = index }, + ChargenAppearanceSlot.FootwearStyle => a with { FootwearStyle = index }, + ChargenAppearanceSlot.FootwearColor => a with { FootwearColor = index }, + _ => a, + }; + private static RuntimeCommandResult Result(RuntimeCommandStatus status) => new(status, Generation); @@ -680,17 +975,34 @@ public sealed class CharacterCreationUiControllerTests MotionTableId: 0u, CombatTableId: 0u, BaseObjDesc: ChargenObjDesc.Empty, - HairColors: [], - HairStyles: [], - EyeColors: [], - EyeStrips: [], - NoseStrips: [], - MouthStrips: [], - Headgears: [], - Shirts: [], - Pants: [], - Footwear: [], - ClothingColors: []); + // Campaign CC slice CC6b-MOUNT: non-empty appearance lists + // so the Appearance page's spin-cycle/wrap and swatch/shade + // dispatch tests have real option counts to exercise (the + // CC4 fixture left these empty since no page read them yet). + HairColors: [0x1000u, 0x1001u, 0x1002u], + HairStyles: + [ + new ChargenHairStyle(IconId: 1u, Bald: false, AlternateSetup: 0u, ObjDesc: ChargenObjDesc.Empty), + new ChargenHairStyle(IconId: 2u, Bald: false, AlternateSetup: 0u, ObjDesc: ChargenObjDesc.Empty), + new ChargenHairStyle(IconId: 3u, Bald: true, AlternateSetup: 0u, ObjDesc: ChargenObjDesc.Empty), + ], + EyeColors: [0x2000u, 0x2001u], + EyeStrips: + [ + new ChargenEyeStrip(IconId: 1u, BaldIconId: 1u, ObjDesc: ChargenObjDesc.Empty, BaldObjDesc: ChargenObjDesc.Empty), + new ChargenEyeStrip(IconId: 2u, BaldIconId: 2u, ObjDesc: ChargenObjDesc.Empty, BaldObjDesc: ChargenObjDesc.Empty), + ], + NoseStrips: [new ChargenFaceStrip(IconId: 1u, ObjDesc: ChargenObjDesc.Empty)], + MouthStrips: [new ChargenFaceStrip(IconId: 1u, ObjDesc: ChargenObjDesc.Empty)], + Headgears: + [ + new ChargenGearOption("Cloth Cap", ClothingTableId: 1u, WeenieDefaultId: 1u), + new ChargenGearOption("Leather Cap", ClothingTableId: 2u, WeenieDefaultId: 2u), + ], + Shirts: [new ChargenGearOption("Tunic", ClothingTableId: 3u, WeenieDefaultId: 3u)], + Pants: [new ChargenGearOption("Trousers", ClothingTableId: 4u, WeenieDefaultId: 4u)], + Footwear: [new ChargenGearOption("Boots", ClothingTableId: 5u, WeenieDefaultId: 5u)], + ClothingColors: [0x3000u, 0x3001u, 0x3002u]); var templates = new List { @@ -841,7 +1153,7 @@ public sealed class CharacterCreationUiControllerTests root.Children.Add(BuildHeritagePage()); root.Children.Add(BuildProfessionPage()); root.Children.Add(BuildSkillsPage()); - root.Children.Add(ContainerInfo(CharacterCreationUiController.AppearancePageElementId)); + root.Children.Add(BuildAppearancePage()); root.Children.Add(BuildTownPage()); root.Children.Add(ContainerInfo(CharacterCreationUiController.SummaryPageElementId)); @@ -947,6 +1259,82 @@ public sealed class CharacterCreationUiControllerTests return page; } + /// Campaign CC slice CC6b-MOUNT: the Appearance page fixture. + /// Spin geometry (each 200px wide, arrow children at the live-DAT- + /// measured x=80/127 — see 's + /// own doc comment) mirrors the real installed layout exactly so the + /// same OnClickAt zone math this page uses in production is what these + /// tests exercise. + private static ElementInfo BuildAppearancePage() + { + var page = new ElementInfo + { + Id = CharacterCreationUiController.AppearancePageElementId, + Type = 3u, + Width = 800f, + Height = 500f, + }; + + page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.FemaleButtonId)); + page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.MaleButtonId)); + page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.FaceButtonId)); + page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.ClothesButtonId)); + page.Children.Add(ContainerInfo(CharacterCreationAppearancePage.FaceChoicesId)); + page.Children.Add(ContainerInfo(CharacterCreationAppearancePage.ClothesChoicesId)); + + foreach (uint spinId in new[] + { + CharacterCreationAppearancePage.HairSpinId, + CharacterCreationAppearancePage.EyesSpinId, + CharacterCreationAppearancePage.NoseSpinId, + CharacterCreationAppearancePage.MouthSpinId, + CharacterCreationAppearancePage.SkinSpinId, + CharacterCreationAppearancePage.HeadgearSpinId, + CharacterCreationAppearancePage.ShirtSpinId, + CharacterCreationAppearancePage.TrousersSpinId, + CharacterCreationAppearancePage.FootwearSpinId, + }) + { + page.Children.Add(SpinInfo(spinId)); + } + + foreach (uint swatchId in CharacterCreationAppearancePage.SwatchIds) + page.Children.Add(ButtonInfo(swatchId)); + + page.Children.Add(ScrollbarInfo(CharacterCreationAppearancePage.ShadeScrollId)); + page.Children.Add(ContainerInfo(CharacterCreationAppearancePage.GradCircleId)); + + var viewport = new ElementInfo + { + Id = CharacterCreationAppearancePage.ViewportId, + Type = 0xDu, + Width = 300f, + Height = 300f, + }; + page.Children.Add(viewport); + + page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.RotateClockwiseId)); + page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.RotateCounterClockwiseId)); + page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.ZoomInId)); + page.Children.Add(ButtonInfo(CharacterCreationAppearancePage.ZoomOutId)); + + return page; + } + + private static ElementInfo SpinInfo(uint id) + { + var spin = new ElementInfo + { + Id = id, + Type = 1u, + Width = 200f, + Height = 24f, + }; + spin.Children.Add(new ElementInfo { Id = 0x1000030Au, Type = 1u, X = 80f, Width = 47f, Height = 24f }); + spin.Children.Add(new ElementInfo { Id = 0x1000030Bu, Type = 1u, X = 127f, Width = 47f, Height = 24f }); + return spin; + } + private static UiElement BuildSkillRowTemplate(uint templateElementId) => LayoutImporter.Build( new ElementInfo