diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 023a0877..3f22d4e9 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) — 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) +## 2. Adaptation (AD) — 77 active rows (AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate visible-cell availability, full-catalog containment-root validation, and the @@ -193,13 +193,12 @@ readiness/requeue adaptation. See | AD-97 | **Filed 2026-08-14 at Campaign LA slice LA7a (character-restore request tail).** Retail's `CharacterRestore` request (`0xF7D9`) is ≥16 bytes: `CPlayerSystem::RestoreCharacter @0x0055d760` is, in the PDB-paired binary, `push 0x008173B4; push 0x008173B4; push guid; call Proto_UI::SendAdminRestoreCharacter @0x00546cf0`, and the callee packs BOTH constant `PStringBase*` arguments (`PStringBase::Pack @0x004fc6f0` emits ≥4 bytes even empty). Binary Ninja renders the two pushes as an uninitialized `edx` local plus `this` — a rendering artifact around constant `0x008173B4` (all 3 of its other pseudo-C appearances sit in provably-broken decompiles), but the arguments are real. acdream sends the 8-byte guid-only form. What the two constant strings contain is unresolved (a live cdb `db poi(0x008173b4)` would settle it). | `src/AcDream.Core.Net/Messages/CharacterRestore.cs` (`BuildRequestBody`) | ACE reads only `ReadUInt32()` and ignores any tail (`CharacterHandler.cs:331-385`), and holtburger ships guid-only from a real client command path against ACE successfully — the tail is unread by every server we can test against, and packing two strings whose CONTENT we cannot verify would be a guess. | A byte-capture comparison against a real retail client differs from offset 8; a future server that validates the full retail shape would reject our 8-byte request. | `CPlayerSystem::RestoreCharacter @0x0055d760` (binary bytes, not the BN rendering); `Proto_UI::SendAdminRestoreCharacter @0x00546cf0`; `PStringBase::Pack @0x004fc6f0`; ACE `CharacterHandler.cs:331-385`; holtburger `character_selection.rs:79-82`; LA7a Opus review F1 (2026-08-14) | | AD-93 | **Filed 2026-08-13 at social gate round 2, item 5 (the refused-drop notice port).** Two narrow gaps in the `ServerSaysAttemptFailed @0x0058EAE0` port: (1) **latched-guid preference** — retail's 0x00A0 dispatcher (`@0x0055B342`) PREFERS `prevRequestObjectID` over the wire guid when picking the item to name; acdream's `InventoryTransactionState.OnMoveFailed` instead REQUIRES the wire guid to match the latch (unobservable against ACE, which always sends the request's own guid on 0x00A0, and it protects a stale latch from mislabeling an unrelated failure — acdream has no retail-style latch timeout). (2) **unlatched request kinds** — retail latches `IR_MOVE`/`IR_WIELD` too; acdream's kind enum has no Move/Wield rows because wields ride `AutoWieldController` outside the single-request gate, so a refused wield/3D-move shows only the generic `HandleFailureEvent` leg, never "The X can't be wielded/moved". | `src/AcDream.Core/Items/InventoryTransactionState.cs` (`OnMoveFailed`); `src/AcDream.Core/Chat/InventoryFailureMessages.cs` (`Compose`'s absent Move/Wield rows); `src/AcDream.App/UI/ItemInteractionController.cs` (`OnInventoryRequestFailed`) | The match requirement is the compensating guard for the missing latch timeout; adding Wield/Move kinds means routing those sends through the single-request gate they deliberately bypass today — a behavior change beyond this gate item. | Only observable against a server that sends 0x00A0 with a guid that differs from the request's item (ACE never does), or on a refused wield/move, which shows no "can't be wielded/moved" verb line where retail would show one. | `ACCWeenieObject::ServerSaysAttemptFailed @0x0058EAE0`; the 0x00A0 dispatcher `@0x0055B342`; `ACCWeenieObject::RecordRequest @0x0058C220`; `docs/research/2026-08-13-confirm-and-weenie-error-display.md` §2 | | AD-100 | **Filed 2026-08-15 at the Campaign CC CC2 review, finding F2 (unrequested `0xF643` handling).** When a `0xF643` (`CharGenVerificationResponse`) arrives with NO outstanding create/restore request, acdream DROPS the message with a once-per-session stderr log. Retail has no such gate: `Handle_CharGenVerificationResponse @0x0055E8B0` processes whatever arrives, discriminating create-vs-restore by its OWN persistent verification state (case 1 branches on `GetVerificationState() == PENDING` → new `CharacterIdentity` + `AddIdentity`, else unpacks into the existing identity at `slot`) — an unsolicited reply would be applied against whatever that state happens to be. acdream's transport-level latch (`PendingCharGenVerificationRequest`) is the equivalent discriminator, but when it is `None` there is no state to apply the reply against, so the honest move is drop-and-log rather than guessing a family. | `src/AcDream.Core.Net/WorldSession.cs` (the `CharGenVerificationResponse.ResponseOpcode` arm in `ProcessDatagram`; `_loggedUnexpectedCharGenVerificationResponse`) | Processing an unsolicited reply requires retail's persistent chargen verification state, which lives in CC3's Runtime owner, not the transport. Until then a reply with no outstanding request is either a server bug or a latch-lifecycle bug on our side — surfacing it in the log beats silently misrouting it to an arbitrary event. Pinned by `WorldSessionCharacterCreationTests.ResponseWithNoOutstandingRequest_IsDroppedAndNeverMisattributed`. | A server that sends a spontaneous/duplicate `0xF643` (ACE can double-send NameInUse — see the CC2 review's F3 note) has its second copy dropped here, where retail would re-process it. If CC3's verification gate ever needs retail's re-process semantics, this drop must move behind that owner's state. | `Handle_CharGenVerificationResponse @0x0055E8B0`; `CharGenState::GetVerificationState`; CC2 review F2 (2026-08-15) | -| AD-103 | **Filed 2026-08-15 at Campaign CC slice CC4 (chargen avail/health/stamina/mana displays and the Skills page credits meter).** Retail's `gmCGProfessionPage`/`gmCGSkillsPage` address these five values as independently-addressable `UIElement_Text` children (`DynamicCast(0xc)`) nested one level under a `UIElement_Button` container/badge (decomp ids `0x100002f1`/`0x100002f3` under `0x100003e2..e5` and `0x100003f9`). acdream's `UiButton.ConsumesDatChildren` swallows every dat child of a Type-1 element at import time (it treats them as label/face art, never as independently addressable overlay widgets — the same convention `UiMeter`'s explicit Type-12 carve-out exists to work around). Live-DAT probe evidence (`CharacterCreationLiveDatTests`) confirms this shape in the installed EoR build. acdream substitutes the CONTAINER button's own `.Label` for the swallowed child's text — same visible number, different addressable widget. | `src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs` (`_availableValue`/`_healthValue`/`_staminaValue`/`_manaValue`, `SetDisplay`); `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (`_credits`) | `UiButton.ConsumesDatChildren` is a structural, campaign-wide convention (shared with every other retained-UI button in the client, not special-cased for chargen); reproducing retail's literal nested-overlay-widget tree here would require the SAME `UiMeter`-style carve-out for every button that happens to author a Type-12 child, a wider change than this slice's scope. **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-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) — 164 active rows (recount at this same edit: the row count this header carried before Batch B was already one high relative to the physical table — a pre-existing drift this edit corrects to the counted total, not an artifact of Batch B's own net change; AP-222 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-11b) — the Appearance spins' current-part highlight and the Town buttons' Normal-to-white caption swap both port retail's actual mechanism (per-state label color/outline commit off the REQUESTED retail state id, independent of art-media availability — `UiButton.SetPerStateLabelStyle`/`ComputeRequestedStateId`), closing the row's own "not yet resolved which side is wrong" question: NEITHER client's spin ART changes (no Highlight media exists on either), but BOTH clients' spin TEXT does, matching retail's `SetState(1)`/`SetState(6)` property commit exactly (live-DAT-measured 218,167,85 -> 255,221,131, outline off -> on); AP-215 NARROWED the same batch (GF-9) — item 1 (the swatch-selection substitution) is RETIRED now that the real companion-overlay mechanism (`SetColor`'s `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) is ported (`CharacterCreationAppearancePage`'s nine `SwatchOverlayIds`), leaving only item 2 (the icon-less style-spin ordinal label) open; AP-230 filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13) — the chargen-scoped-vs-general-importer-wide honor split for dat property 0x3B (Invisible: `UIElement::OnSetAttribute` case 8 hides an element), with the general client-wide honor deferred as its own visual gate (docs/ISSUES.md #408, 1,083 elements affected); AP-213 NARROWED the same gate round (GF-5) — the Skills page's click-to-advance/double-click-retreat single-button substitution is RETIRED now that the real per-row `pSkillUpButton`/`pSkillDownButton` arrows are wired to retail's own plain-click dispatch, leaving open only the flat-list-vs-four-bucket-sorted-model half; AP-229 filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1 — the screen-layering divergence: retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch where acdream's CC7 keeps both `CharacterManagementUiController` and `CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes them; AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 163 active rows (AP-218 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-6) — `gmCGAppearancePage::Update`'s heritage-flavored static Hair/Eyes/Skin spin caption (`ID_CharGen_HairStyle`/`_Eyes`/`_Skin`, Gearknight `GearText_*`, Olthoi/OlthoiAcid `OlthoiText_*`) is now ported verbatim by `RefreshSpinCaptions`, replacing the prior ordinal substitution outright — see AP-215's own rewritten row for what remains open (the icon-thumbnail gap, restated); recount at this same edit: the row count this header carried before Batch B was already one high relative to the physical table — a pre-existing drift this edit corrects to the counted total, not an artifact of Batch B's own net change; AP-222 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-11b) — the Appearance spins' current-part highlight and the Town buttons' Normal-to-white caption swap both port retail's actual mechanism (per-state label color/outline commit off the REQUESTED retail state id, independent of art-media availability — `UiButton.SetPerStateLabelStyle`/`ComputeRequestedStateId`), closing the row's own "not yet resolved which side is wrong" question: NEITHER client's spin ART changes (no Highlight media exists on either), but BOTH clients' spin TEXT does, matching retail's `SetState(1)`/`SetState(6)` property commit exactly (live-DAT-measured 218,167,85 -> 255,221,131, outline off -> on); AP-215 NARROWED the same batch (GF-9) — item 1 (the swatch-selection substitution) is RETIRED now that the real companion-overlay mechanism (`SetColor`'s `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) is ported (`CharacterCreationAppearancePage`'s nine `SwatchOverlayIds`), leaving only item 2 (the icon-less style-spin ordinal label) open; AP-230 filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13) — the chargen-scoped-vs-general-importer-wide honor split for dat property 0x3B (Invisible: `UIElement::OnSetAttribute` case 8 hides an element), with the general client-wide honor deferred as its own visual gate (docs/ISSUES.md #408, 1,083 elements affected); AP-213 NARROWED the same gate round (GF-5) — the Skills page's click-to-advance/double-click-retreat single-button substitution is RETIRED now that the real per-row `pSkillUpButton`/`pSkillDownButton` arrows are wired to retail's own plain-click dispatch, leaving open only the flat-list-vs-four-bucket-sorted-model half; AP-229 filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1 — the screen-layering divergence: retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch where acdream's CC7 keeps both `CharacterManagementUiController` and `CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes them; AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -396,10 +395,9 @@ 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); NARROWED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-9) — item 1 (the swatch-selection substitution) RETIRED.** What CLOSED this round: the nine color swatches (`0x1000030f-0x10000317`) now drive the SAME companion overlay elements retail's own `SetColor @0x0047DD50` toggles (`m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) — `CharacterCreationAppearancePage.RefreshColorAndShadeControls` shows exactly the overlay (`0x10000318-0x10000320`, `SwatchOverlayIds`) at the currently-selected color index and hides the rest, retiring the prior `UiButton.Selected` highlight substitution outright (measured against the installed dat: the swatch buttons author only an unnamed DirectState sprite with no Normal/Highlight media at all, so that substitution was ALWAYS a complete no-op — the retired AP-222's own sibling finding). **Still open (unchanged, out of this round's scope):** 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 remains out of scope entirely (no icon-texture pipeline is wired to ANY chargen widget yet). | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s overlay loop, CLOSED this round; `SetStyleSpinLabel`, still open) | The ordinal still lets a player cycle deterministically and see which slot they're on through an existing widget primitive (`UiButton.Label`) rather than adding an icon-texture pipeline this slice's scope doesn't otherwise need. | A pixel-level side-by-side against retail would show a numbered ordinal 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 this row. | `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip`/`ChargenGearOption` (CC1, `src/AcDream.Core/CharGen/ChargenAppearanceOptions.cs`) | -| AP-216 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 1).** Retail's `gmCGAppearancePage::DoColorSpots @0x0047d850` blits each of the nine swatch buttons with the ACTUAL color it represents (computed from the current part's own palette) and blits blank art for any swatch beyond the current part's real color count. acdream's swatches show only their authored (static) DAT art regardless of which color they represent or whether the current part even has that many colors — AP-215's `.Selected` substitution covers WHICH swatch is chosen, not what each swatch itself looks like. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s swatch loop — sets `.Selected` only, never touches swatch appearance) | The nine swatches already reach the correct SELECTION semantics through `DatWidgetFactory`'s existing `UiButton` primitive; painting each swatch with a computed color needs either a per-swatch dynamic-color render path (new UI infrastructure this scope doesn't otherwise need) or a fallback to static art, which is what this round shipped. | A side-by-side against retail shows every swatch drawing the SAME authored art regardless of which color it represents, and swatches beyond a part's real color count staying visibly "on" instead of blanking — a real visual gap on a screen the player stares at while picking a color, not a selection-correctness gap. | `gmCGAppearancePage::DoColorSpots @0x0047d850` | -| AP-217 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 4); rewritten 2026-08-15 at the re-review of fix commit `d2a71152` (R3) — the original row misdescribed both the retail mechanism and the acdream gap.** `gmCGAppearancePage::ListenToElementMessage @0x0047ef30`'s dispatch switch on `idElement - 0x1000030a` has NO `case 4` (present cases: `0`,`1`,`5`-`0xd`,`0x17`,`0x19`-`0x1c`,`0xa5`-`0xa9`,`0xab`-`0xae`) — retail routes NO UI message from the GradCircle (`0x1000030e`, offset `4`) at all; it is not a click target. `DoGradDisk @0x0047da90` is a PAINT-only routine, called from `SetColor` (`@0x0047de18`) and `SetSelection` (`@0x0047e873`/`@0x0047e85d`): it `BlitAndColor`s the gradient graphic with the current part's color and `UIRegion::SetImage`s it onto `m_pGradCircle` (`@0x0047dc9e`/`@0x0047dca9`/`@0x0047dd26`) for every part except Eyes, or blits the blank "grad plug" graphic instead (`@0x0047dcec`, `DoGradDisk(this, 1)`) for Eyes — the GradCircle is authored, retail-driven *decorative art reflecting the current color*, not an input control. acdream imports the GradCircle through the generic Type-3 `UiDatElement` fallback and never paints it: no `BlitAndColor`-equivalent repaint on color change, and no Eyes-blank equivalent. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`GradCircleId` is resolved by the live-DAT test only; the page never repaints it) | The nine swatch buttons already provide the full, decomp-cited color-selection input path (`SetColor`'s own cases `5`-`0xd`); porting the GradCircle's own gradient-graphic repaint (a `Blit_Multiply` composite against `m_pGradGraphic`/`m_pGradPlug`, not a click handler) is separate follow-up work with no decomp citation yet for the composite art assets. | A user in acdream sees the GradCircle stay static instead of visually reflecting the current swatch color (and never blanking for Eyes) — a cosmetic paint gap, not a dead/unresponsive control; clicking it does nothing in retail either. | `gmCGAppearancePage::ListenToElementMessage @0x0047ef30`; `gmCGAppearancePage::DoGradDisk @0x0047da90`; `gmCGAppearancePage::SetColor @0x0047dd50`; `gmCGAppearancePage::SetSelection @0x0047e260` (calls at `@0x0047e873`/`@0x0047e85d`) | -| AP-218 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 5).** Retail's `gmCGAppearancePage::Update` sets the Hair/Eyes/Skin spins' text to a heritage-flavored STATIC caption via `UIElement_Text::SetStringInfoWithFont` — normal heritage: `ID_CharGen_HairStyle`/`ID_CharGen_Eyes`/`ID_CharGen_Skin`; Olthoi/OlthoiAcid: `ID_CharGen_OlthoiText_HairButton`/`_EyesButton`/`_SkinButton`; Gearknight: `ID_CharGen_GearText_HairButton`/`_EyesButton`/`_SkinButton`. acdream's `SetStyleSpinLabel` instead overwrites the SAME label slot with a raw 1-based ordinal (or `"-"` when Unset) on all four icon-only spins (Hair/Eyes/Nose/Mouth) — neither the caption text nor its heritage-specific swap survives, and the ordinal itself is already a scope-cut stand-in for retail's icon thumbnail (CC1/AP-215). | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`SetStyleSpinLabel`) | The icon-rendering gap (CC1/AP-215) already means the spin can't show retail's icon thumbnail either way this round; reusing the SAME `.Label` slot for a numeric position indicator gives the player SOME feedback about which style is selected without adding a second text element this round's widget catalog doesn't otherwise carry. | A side-by-side against retail shows a numbered ordinal where retail shows static caption text (heritage-flavored) with an icon for the value — a cosmetic/informational gap, not a selection-correctness gap; a Gearknight or Olthoi player sees the SAME generic ordinal a normal-heritage player would, losing the heritage-specific caption entirely. | `gmCGAppearancePage::Update` caption writes @0x0047ebad (`ID_CharGen_HairStyle`), @0x0047ebe3 (`ID_CharGen_Eyes`), @0x0047ec6a (`ID_CharGen_Skin`); @0x0047ed5b/@0x0047ed91/@0x0047ee15 (Olthoi `OlthoiText_*` variants); @0x0047e9ef/@0x0047ea25/@0x0047eaa9 (Gearknight `GearText_*` variants) | +| AP-215 | **Filed 2026-08-15 at Campaign CC slice CC6b-MOUNT (Appearance page visual substitutions); NARROWED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-9) — item 1 (the swatch-selection substitution) RETIRED; RE-NARROWED 2026-08-16 at Batch C fix (GF-6/AP-218) — the "1-based ordinal" framing of item 2 is now STALE and replaced below.** What CLOSED at Batch B: the nine color swatches (`0x1000030f-0x10000317`) now drive the SAME companion overlay elements retail's own `SetColor @0x0047DD50` toggles (`m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) — `CharacterCreationAppearancePage.RefreshColorAndShadeControls` shows exactly the overlay (`0x10000318-0x10000320`, `SwatchOverlayIds`) at the currently-selected color index and hides the rest. What CLOSED at Batch C: `SetStyleSpinLabel`'s 1-based-ordinal substitution is GONE — `RefreshSpinCaptions` now writes retail's own heritage-flavored STATIC caption (see AP-218, RETIRED). **Still open (RESTATED, not the same gap the ordinal covered):** the four icon-only style spins (hair/eyes/nose/mouth — CC1's `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip` carry only an `IconId`, no name string) now show the SAME static caption regardless of which style is selected — retail's own per-choice visual feedback there is an ICON THUMBNAIL this port still doesn't render (no icon-texture pipeline is wired to ANY chargen widget); the live 3D preview is the player's only feedback for which style is currently active. The four clothing spins (headgear/shirt/trousers/footwear) show a real name via `ChargenGearOption.Name` and have no icon gap. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s overlay loop, CLOSED Batch B; `RefreshSpinCaptions`, static-caption-only, icon gap still open) | An icon-texture pipeline for the four icon-only spins is new UI infrastructure this round's scope doesn't otherwise need; the static caption alone is retail-faithful for the TEXT half. | A pixel-level side-by-side against retail would show no icon thumbnail next to the four icon-only spins' caption (cosmetic gap only — the caption text itself is now byte-correct, and the live 3D preview still shows the actual selection). A future icon-rendering pass (if chargen ever needs one, e.g. for the heritage/template icons too) would naturally close this row. | `ChargenHairStyle`/`ChargenEyeStrip`/`ChargenFaceStrip`/`ChargenGearOption` (CC1, `src/AcDream.Core/CharGen/ChargenAppearanceOptions.cs`) | +| AP-216 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 1); PARTIALLY CLOSED 2026-08-16 at the Campaign CC gate round 1 Batch C fix.** Retail's `gmCGAppearancePage::DoColorSpots @0x0047d850` blits each of the nine swatch buttons with the ACTUAL color it represents (computed from the current part's own palette) and blits blank art for any swatch beyond the current part's real color count. **What CLOSED:** the "beyond the count" half — `CharacterCreationAppearancePage.RefreshColorAndShadeControls` now hides (`Visible=false`) any swatch index at or past the current part's own `ColorCount`, the acdream equivalent of retail's blank blit. **Still open:** the "actual color" half — acdream's swatches still show only their authored (static) DAT art regardless of which color they individually represent; painting each swatch with its own computed color needs a PalSet/Palette-id -> RGB resolution pipeline no chargen page currently reads DAT palette pixels through at runtime (new UI infrastructure this batch judged disproportionate to add alongside its ~10 other fixes). | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls`'s swatch loop — hides beyond-count swatches, CLOSED; still sets no per-swatch color, OPEN) | The nine swatches already reach the correct SELECTION semantics AND the correct beyond-count visibility through existing `UiButton`/`UiElement.Visible` primitives; painting each swatch with a computed color needs a genuinely new palette-to-RGB render path this batch's scope didn't otherwise need. | A side-by-side against retail shows every VALID swatch drawing the SAME authored art regardless of which color it represents — a cosmetic gap only now (the beyond-count "stuck visibly on" gap that used to mislead a player about how many real choices existed is closed). | `gmCGAppearancePage::DoColorSpots @0x0047d850` | +| AP-217 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 4); rewritten 2026-08-15 at the re-review of fix commit `d2a71152` (R3); PARTIALLY CLOSED 2026-08-16 at the Campaign CC gate round 1 Batch C fix.** `gmCGAppearancePage::ListenToElementMessage @0x0047ef30`'s dispatch switch on `idElement - 0x1000030a` has NO `case 4` (present cases: `0`,`1`,`5`-`0xd`,`0x17`,`0x19`-`0x1c`,`0xa5`-`0xa9`,`0xab`-`0xae`) — retail routes NO UI message from the GradCircle (`0x1000030e`, offset `4`) at all; it is not a click target. `DoGradDisk @0x0047da90` is a PAINT-only routine, called from `SetColor` (`@0x0047de18`) and `SetSelection` (`@0x0047e873`/`@0x0047e85d`): it `BlitAndColor`s the gradient graphic with the current part's color and `UIRegion::SetImage`s it onto `m_pGradCircle` (`@0x0047dc9e`/`@0x0047dca9`/`@0x0047dd26`) for every part except Eyes, or blits the blank "grad plug" graphic instead (`@0x0047dcec`, `DoGradDisk(this, 1)`) for Eyes. **What CLOSED:** the Eyes-blank half — `CharacterCreationAppearancePage.RefreshColorAndShadeControls` now hides the GradCircle when the current part is Eyes, the acdream equivalent of the blank "grad plug" blit. **Still open:** the gradient-graphic TINT half — acdream still never repaints the GradCircle with the current part's color; that composite (`Blit_Multiply` against `m_pGradGraphic`/`m_pGradPlug`) needs the SAME palette-to-RGB resolution pipeline AP-216's still-open half needs, so it stays open for the same reason. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`RefreshColorAndShadeControls` now hides the GradCircle for Eyes, CLOSED; still never repaints it for any other part, OPEN) | The nine swatch buttons already provide the full, decomp-cited color-selection input path (`SetColor`'s own cases `5`-`0xd`); porting the GradCircle's own gradient-graphic repaint is genuinely new render infrastructure, same as AP-216's open half. | A user in acdream sees the GradCircle stay static instead of visually reflecting the current swatch color for any part OTHER than Eyes (Eyes now correctly blanks) — a cosmetic paint gap, not a dead/unresponsive control; clicking it does nothing in retail either. | `gmCGAppearancePage::ListenToElementMessage @0x0047ef30`; `gmCGAppearancePage::DoGradDisk @0x0047da90`; `gmCGAppearancePage::SetColor @0x0047dd50`; `gmCGAppearancePage::SetSelection @0x0047e260` (calls at `@0x0047e873`/`@0x0047e85d`) | | AP-219 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 6).** Retail's `gmCGAppearancePage::Update` repositions the Skin spin vertically when Nose/Mouth are hidden, closing the gap those two spins would otherwise leave: `m_pSkinSpin->MoveTo(0, 0x5a)` (Y=90) for Olthoi/OlthoiAcid (`@0x0047edef`) and Gearknight (`@0x0047ea83`), vs `MoveTo(0, 0xb4)` (Y=180) for every other heritage (`@0x0047ec41`). acdream hides Nose/Mouth (`Refresh`'s `clothesHidden` branch) but never repositions Skin, leaving a visible vertical gap in the Face tab's spin list for these three heritages. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh`'s `clothesHidden` branch — hides Nose/Mouth, never moves Skin) | The spins are laid out via their authored LayoutDesc positions (`DatWidgetFactory`), which this campaign's slice doesn't runtime-reposition for any other case; the targeted behavior this round was visibility (hiding unreachable spins), not repositioning the ones that remain. | A side-by-side against retail on Olthoi/OlthoiAcid/Gearknight shows a visible vertical gap where Nose/Mouth used to sit, instead of Skin sliding up to close it — a layout/cosmetic gap, not a functional one. | `gmCGAppearancePage::Update` `MoveTo` calls `@0x0047edef` (Olthoi/OlthoiAcid), `@0x0047ea83` (Gearknight), `@0x0047ec41` (every other heritage, the "normal" position) | | AP-220 | **Filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round (F2 item 7); tightened 2026-08-15 at the re-review of fix commit `d2a71152` (N1) — "leaving Gearknight for something else" over-claimed the exit side.** Retail's `gmCGAppearancePage::Update` calls `CharGenState::RandomizeAppearance(state, 0)` + `CharGenState::RandomizeClothing(state, 1)` exactly once, on the SPECIFIC frame the heritage crosses the Gearknight boundary in either direction — entering Gearknight from something else (`@0x0047e973`, gated on `m_LastHeritageGroup != 6`) or leaving Gearknight for a non-Olthoi heritage (`@0x0047eb58`, gated on `m_LastHeritageGroup == 6` inside the `else` arm of the `mHeritageGroup == 0xc || mHeritageGroup == 0xd` Olthoi/OlthoiAcid test `@0x0047eb46` — leaving Gearknight FOR Olthoi or OlthoiAcid takes the Olthoi-specific `if` arm instead and does NOT randomize). acdream's `Refresh` (the `Update` analogue) has no heritage-transition-edge tracking at all and never calls anything on a Gearknight-boundary crossing. | `src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs` (`Refresh` — no `_lastHeritageId`-style transition tracking or randomize call) | This is the SAME six-primitive gap AP-212 (the Random button) and AP-214 (ctor-time `RandomizeCharacter`) already track — `RandomizeAppearance`/`RandomizeClothing` are two of AP-212's six named-but-unported `CharGenState` primitives; a THIRD call site for the identical missing primitives doesn't widen the underlying gap, just where it's also reachable. | Switching heritage into or out of Gearknight in acdream leaves the character's prior appearance/clothing selections untouched (whatever indices were already set, now possibly out-of-range and silently clamped by `ConstrainAppearanceByGenderLocked` rather than freshly randomized), where retail re-rolls both — a behavioral gap a connected gate switching heritage to/from Gearknight would observe directly. | `gmCGAppearancePage::Update` `@0x0047e973` (entering Gearknight) and `@0x0047eb58` (leaving Gearknight); `CharGenState::RandomizeAppearance @0x005c4f10`; `CharGenState::RandomizeClothing @0x005c6770` (both already cited by AP-212) | | AP-221 | **Filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (R2) — records the F8 one-shot-binding disposition the re-reviewer accepted as a scoped, documented call, but which shipped without a register row of its own. AMENDED at the CC5 review-fix round, F7 (2026-08-16): this row's own "Risk" column named CC5 as the slice that "should close" this gap; CC5 instead DUPLICATED the same one-shot pattern for a second private viewport (the Summary preview) rather than closing it, and the duplicate shipped without extending this row to cover it — corrected below.** The chargen Appearance-page preview's GPU-side renderer/viewport binding in `LivePresentationComposition`'s chargen block reads `RetailUiRuntime.ChargenPreviewViewportWidget` exactly ONCE, synchronously, during the single `GameWindow.OnLoad` composition pass. `ChargenPreviewViewportWidget` is computed-through `CharacterCreationUiMountCoordinator`, which IS explicitly retryable/idempotent — ticked once per frame (via `RetailUiRuntime.Tick`) until its own DAT/resource read succeeds. If the coordinator's synchronous construction-time mount has NOT succeeded by that one composition pass (DATs not readable on that exact frame), the coordinator's later per-frame retries can still restore the rest of the mounted chargen SCREEN, but this GPU-side lease/binding is never retried — the preview stays permanently unbound for the rest of the session: no lease acquired, no renderer assigned to `chargenViewport`, `RetailUiRuntime.ChargenPreviewControl` never set, and the Appearance page's zoom/rotate controls silently no-op for the whole session. The narrowed diagnostic added at R1 (this same commit) is the only operator-visible evidence, and only fires when retained UI is actually mounted. **The Summary preview block (CC5, immediately below the Appearance block in the same method) is the SAME shape against a SECOND independent lease/binding pair (`summaryPreviewLease`/`summaryPreviewController`, `RetailUiRuntime.SummaryPreviewViewportWidget`/`SummaryPreviewControl`) — a DAT/resource miss on that one composition pass leaves the Summary page's 3D preview permanently unbound for the session with only its own narrowed `Console.WriteLine` diagnostic as evidence (no zoom/rotate controls to lose there, since retail's own Summary viewport has none — see `RetailSummaryPreviewPageVisibility`'s doc comment — but the idle-animated preview itself never renders).** | `src/AcDream.App/Composition/LivePresentationComposition.cs` (the chargen preview viewport block, the `if (dispatcherLease.Resource is { } chargenDispatcher && interaction.RetainedUi?.Runtime.ChargenPreviewViewportWidget is { } chargenViewport)` arm and its `else if` diagnostic, plus the Summary preview block's identical `summaryDispatcher`/`SummaryPreviewViewportWidget` arm immediately after it); `src/AcDream.App/UI/RetailUiRuntime.cs` (`ChargenPreviewViewportWidget`, `SummaryPreviewViewportWidget`); `src/AcDream.App/UI/Layout/CharacterCreationUiMountCoordinator.cs` | Retrofitting cross-frame retry into this one binding would mean restructuring the whole composition's one-shot GPU-resource-wiring contract shared by paperdoll (`PaperdollViewportWidget`), creature-appraisal, AND now the Summary preview in the SAME method, plus the fixed `PrivateEntityViewportFrameGroup` array `FrameRootComposition` builds from the result — out of both the CC6b-MOUNT fix round's AND CC5's blast radius; each round accepted the narrower diagnostic-only fix as sufficient, with this row as the tracked follow-up for BOTH bindings now. | On the specific unlucky frame where either coordinator's construction-time `Tick()` has not yet succeeded (a DAT/resource read not ready that frame), a user gets a chargen screen that otherwise mounted fine but whose Appearance 3D preview zoom/rotate controls, OR whose Summary 3D preview entirely, is dead for the ENTIRE session with no visible error beyond the respective narrowed console diagnostic — a session-permanent, hard-to-reproduce loss a future retry-aware rewrite of BOTH bindings should close together (a single fix, not two). | `src/AcDream.App/Composition/LivePresentationComposition.cs:1001-1109` (chargen preview block's own F8 disposition comment) and `:1111-1185` (the Summary preview block, same disposition, referencing this row); `RetailUiRuntime.ChargenPreviewViewportWidget`/`SummaryPreviewViewportWidget`'s doc comments (retry-vs-one-shot contrast) | diff --git a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs index 92b54b49..37bd2929 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationAppearancePage.cs @@ -1,4 +1,3 @@ -using System.Globalization; using AcDream.App.Rendering; using AcDream.Core.CharGen; using AcDream.Runtime; @@ -175,6 +174,7 @@ internal sealed class CharacterCreationAppearancePage : IDisposable private readonly UiButton? _rotateCounterClockwise; private readonly UiButton? _zoomIn; private readonly UiButton? _zoomOut; + private readonly UiElement? _gradCircle; private Choice _currentChoice = Choice.Face; private Part _currentPart = Part.Hair; @@ -242,6 +242,8 @@ internal sealed class CharacterCreationAppearancePage : IDisposable if (_shadeScroll is not null) _shadeScroll.ScalarChanged = SetShadeFromScalar; + _gradCircle = Find(pageRoot, GradCircleId); + Viewport = Find(pageRoot, ViewportId); _rotateClockwise = Find(pageRoot, RotateClockwiseId); @@ -327,8 +329,8 @@ internal sealed class CharacterCreationAppearancePage : IDisposable } ApplyChoiceVisibility(); - if (TryGetGender(view, snapshot, out ChargenGenderOptions? gender)) - RefreshSpins(gender, snapshot.Appearance); + // GF-6: heritage-flavored, index-independent — no gender needed. + RefreshSpinCaptions(snapshot.HeritageId); RefreshColorAndShadeControls(view, snapshot); RebuildPreview(view, snapshot); @@ -677,6 +679,37 @@ internal sealed class CharacterCreationAppearancePage : IDisposable overlay.Visible = colorSlot is not null && currentColor == (uint)i; } + // AP-216 (Campaign CC gate round 1 Batch C, PARTIAL): retail's + // DoColorSpots @0x0047d850 blits ACTUAL-color art for each valid + // swatch and BLANK art for any swatch beyond the current part's + // real color count. Painting each swatch with its own represented + // color needs a PalSet/Palette-id -> RGB resolution pipeline this + // batch does not add (no chargen page currently reads DAT palette + // pixels at runtime) — register AP-216 stays open for that half. + // This ships the cheap, fully-evidenced half: hiding a swatch a + // part's color list doesn't actually have (closest faithful + // rendering the existing pipeline supports — Visible=false is the + // acdream equivalent of "blit nothing"). + int colorCount = colorSlot is not null + && TryGetGender(view, snapshot, out ChargenGenderOptions? swatchGender) + ? ColorCount(_currentPart, swatchGender) + : 0; + for (int i = 0; i < _swatches.Length; i++) + { + if (_swatches[i] is { } swatch) + swatch.Visible = colorSlot is not null && i < colorCount; + } + + // AP-217 (PARTIAL): gmCGAppearancePage::DoGradDisk @0x0047da90 + // blits the blank "grad plug" for Eyes (DoGradDisk(this, 1), + // called from SetSelection @0x0047e85d) and a gradient graphic + // TINTED with the current part's color otherwise — the tinted + // repaint needs the same palette-to-RGB pipeline AP-216's open + // half needs, so it stays open too. This ships the evidenced + // Eyes-blank half only. + if (_gradCircle is not null) + _gradCircle.Visible = _currentPart != Part.Eyes; + ChargenShadeSlot? shadeSlot = ShadeSlotFor(_currentPart); if (_shadeScroll is null) return; @@ -694,36 +727,57 @@ internal sealed class CharacterCreationAppearancePage : IDisposable } } - // ── Spin labels ────────────────────────────────────────────────── + // ── Spin captions ──────────────────────────────────────────────── - private void RefreshSpins(ChargenGenderOptions gender, RuntimeCharacterCreationAppearance a) + /// + /// GF-6/AP-218 (Campaign CC gate round 1 Batch C): + /// gmCGAppearancePage::Update @ 0x0047e8f0 writes the Hair/Eyes/ + /// Skin spins' caption to a heritage-flavored STATIC string via + /// UIElement_Text::SetStringInfoWithFont — never an index or a + /// style name. Normal heritage: ID_CharGen_HairStyle/ + /// _Eyes/_Skin (@0x0047ebad/0x0047ebe3/0x0047ec6a). + /// Gearknight (heritage 6): ID_CharGen_GearText_HairButton/ + /// _EyesButton/_SkinButton (@0x0047e9ef/0x0047ea25/ + /// 0x0047eaa9). Olthoi/OlthoiAcid (heritage 0xc/0xd): + /// ID_CharGen_OlthoiText_HairButton/_EyesButton/ + /// _SkinButton (@0x0047ed5b/0x0047ed91/0x0047ee15). The other + /// six spins (Nose/Mouth/Headgear/Shirt/Trousers/Footwear) are NEVER + /// touched by Update — their DAT-authored static caption + /// (already resolved at build time by + /// DatWidgetFactory.BuildButton's own P0x17 lift) is left + /// alone. Retail shows NO per-style index or name anywhere on this + /// page — the live 3D preview is the player's only feedback for which + /// style/gear is currently selected; acdream's own prior "1-based + /// ordinal"/gear-name substitution here was never a retail behavior + /// (register AP-218, retired by this fix; AP-215's own icon-thumbnail + /// item stays open — a DIFFERENT gap, see that row's own text). + /// + private void RefreshSpinCaptions(uint heritageId) { - 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); + (string hairKey, string eyesKey, string skinKey) = heritageId switch + { + (uint)ChargenHeritageGroup.Gearknight => ( + "ID_CharGen_GearText_HairButton", + "ID_CharGen_GearText_EyesButton", + "ID_CharGen_GearText_SkinButton"), + (uint)ChargenHeritageGroup.Olthoi or (uint)ChargenHeritageGroup.OlthoiAcid => ( + "ID_CharGen_OlthoiText_HairButton", + "ID_CharGen_OlthoiText_EyesButton", + "ID_CharGen_OlthoiText_SkinButton"), + _ => ("ID_CharGen_HairStyle", "ID_CharGen_Eyes", "ID_CharGen_Skin"), + }; + + SetSpinCaption(Part.Hair, hairKey); + SetSpinCaption(Part.Eyes, eyesKey); + SetSpinCaption(Part.Skin, skinKey); } - private void SetStyleSpinLabel(Part part, int count, uint index) + private void SetSpinCaption(Part part, string key) { 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"; + if (_bindings.ResolveText?.Invoke(key) is { } text) + spin.Label = text; } // ── Preview rebuild ────────────────────────────────────────────── diff --git a/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs b/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs index 26f7eb58..7a79b465 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs @@ -1,3 +1,4 @@ +using System.Numerics; using AcDream.Core.CharGen; using AcDream.Runtime; using AcDream.Runtime.Session; @@ -72,10 +73,38 @@ internal sealed class CharacterCreationHeritagePage : IDisposable [(uint)ChargenHeritageGroup.Undead] = "ID_CharGen_UndText_BonusSkills_Trained", }; + /// + /// Root 1d (Campaign CC gate round 1 Batch C): the page's own backdrop + /// element (0x100003be, live-DAT-measured 13 authored states) + /// switches per selected heritage — gmCGHeritagePage::Update + /// @0x00483210's per-case m_pBackground->SetState(...) + /// calls (heritages 5/Shadowbound and 10/Penumbraen share literals + /// 0x10000058/0x10000059 via a shared jump target, every + /// other heritage has its own distinct state). + /// + private static readonly IReadOnlyDictionary BackdropStateByHeritage = + new Dictionary + { + [(uint)ChargenHeritageGroup.Aluvian] = 0x10000021u, + [(uint)ChargenHeritageGroup.Gharundim] = 0x10000022u, + [(uint)ChargenHeritageGroup.Sho] = 0x10000023u, + [(uint)ChargenHeritageGroup.Viamontian] = 0x10000024u, + [(uint)ChargenHeritageGroup.Shadowbound] = 0x10000058u, + [(uint)ChargenHeritageGroup.Gearknight] = 0x1000005Au, + [(uint)ChargenHeritageGroup.Tumerok] = 0x1000005Fu, + [(uint)ChargenHeritageGroup.Lugian] = 0x10000060u, + [(uint)ChargenHeritageGroup.Empyrean] = 0x1000005Cu, + [(uint)ChargenHeritageGroup.Penumbraen] = 0x10000059u, + [(uint)ChargenHeritageGroup.Undead] = 0x1000005Bu, + [(uint)ChargenHeritageGroup.Olthoi] = 0x1000005Du, + [(uint)ChargenHeritageGroup.OlthoiAcid] = 0x1000005Eu, + }; + private readonly CharacterCreationRuntimeBindings _bindings; private readonly Action _onButtonClicked; private readonly Dictionary _buttons = []; private readonly UiText? _description; + private readonly UiElement? _backdrop; private bool _disposed; /// Review fix round F3 (2026-08-15): @@ -105,6 +134,7 @@ internal sealed class CharacterCreationHeritagePage : IDisposable } _description = UiElement.FindDescendant(pageRoot, 0x100003C4u) as UiText; + _backdrop = UiElement.FindDescendant(pageRoot, 0x100003BEu); } internal void Refresh( @@ -114,12 +144,24 @@ internal sealed class CharacterCreationHeritagePage : IDisposable foreach ((UiButton button, uint heritageId) in _buttons) button.Selected = heritageId == snapshot.HeritageId; + // Root 1d: switch the backdrop art per selected heritage. Retail + // runs this unconditionally alongside the button highlight/text + // composition below — no heritage-unset guard exists in the decomp + // beyond the dictionary lookup itself (heritageId 0 simply has no + // entry, so TryGetValue leaves the backdrop at whatever state it + // last held, matching retail's own "no case 0" switch shape). + if (_backdrop is IUiDatStateful backdropStateful + && BackdropStateByHeritage.TryGetValue(snapshot.HeritageId, out uint backdropState)) + { + backdropStateful.TrySetRetailState(backdropState); + } + if (_description is null) return; - string composed = ComposeDescription(view, snapshot.HeritageId, _bindings.ResolveText); - _description.LinesProvider = () => - [new UiText.Line(composed, _description.DefaultColor)]; + IReadOnlyList segments = ComposeSegments( + _description, view, snapshot.HeritageId, _bindings.ResolveText); + _description.LinesProvider = () => DatRichText.Compose(_description, segments); } internal void Randomize(RuntimeCharacterCreationSnapshot snapshot) @@ -168,38 +210,45 @@ internal sealed class CharacterCreationHeritagePage : IDisposable /// body, the bonus-skills header, then — only once a heritage is /// selected — that heritage's own bonus-skills line (absent for /// Lugian/Olthoi/OlthoiAcid; see ). - /// is the DAT string lookup - /// (RetailUiRuntime's DatStringResolver over table - /// 0x23000002) threaded through the bindings record; a missing - /// resolver or a missing key degrades to skipping that segment rather - /// than throwing. + /// Header segments use SetStringInfoWithFont's own font-index + /// argument (1 — palette index 1, live-DAT-measured GREEN); + /// body/bonus-body segments use index 0 (white). is the DAT string lookup (RetailUiRuntime's + /// DatStringResolver over table 0x23000002) threaded + /// through the bindings record; a missing resolver or a missing key + /// degrades to skipping that segment rather than throwing. /// - private static string ComposeDescription( + private static IReadOnlyList ComposeSegments( + UiText description, IRuntimeCharacterCreationView view, uint heritageId, Func? resolveText) { + Vector4 headerColor = DatRichText.PaletteColor(description, 1, new Vector4(0f, 1f, 0f, 1f)); + Vector4 bodyColor = DatRichText.PaletteColor(description, 0, Vector4.One); + if (resolveText is null) { - return view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? named) + string name = view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? named) ? named.Name : string.Empty; + return [new DatRichText.Segment(name, bodyColor)]; } - var parts = new List(); + var segments = new List(); if (resolveText("ID_CharGen_Heritage_StartingSkills_Header") is { } header) - parts.Add(header); + segments.Add(new(header, headerColor)); if (resolveText("ID_CharGen_Heritage_StartingSkills") is { } body) - parts.Add(body); + segments.Add(new(body, bodyColor)); if (resolveText("ID_CharGen_Heritage_BonusSkills_Trained_Header") is { } bonusHeader) - parts.Add(bonusHeader); + segments.Add(new(bonusHeader, headerColor)); if (heritageId != 0 && BonusSkillsKeyByHeritage.TryGetValue(heritageId, out string? bonusKey) && resolveText(bonusKey) is { } bonusBody) { - parts.Add(bonusBody); + segments.Add(new(bonusBody, bodyColor)); } - return string.Join("\n\n", parts); + return segments; } public void Dispose() diff --git a/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs index e5ef6434..36a69d64 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationProfessionPage.cs @@ -69,6 +69,65 @@ internal sealed class CharacterCreationProfessionPage : IDisposable private sealed record SliderWidgets(UiButton? Lock, UiScrollbar? Slider, UiField? Value); + /// + /// GF-4b: the six slider containers' name-label CHILD, relative id + /// 0x100002edgmCGProfessionPage::InitializePage + /// @0x00482e1a-0x00482f1d writes CharGenState::GetAttributeName + /// @0x005C3A20's literal ONCE at page construction (no per-refresh + /// rewrite anywhere in the decomp — UpdateAttributeValues only + /// touches pSlider/pAttribValue, never this id). Live-DAT- + /// measured: this child resolves as Type 1 (UIElement_Button), + /// matching retail's own declared UIElement_Button* field type + /// that still accepts UIElement_Text::SetText — retail's button + /// class carries the same text-rendering capability + /// already is in this port. + /// + private const uint SliderNameRelativeId = 0x100002EDu; + + /// + /// GF-3: the description textbox — gmCGProfessionPage::InitializePage + /// @0x00483068's m_pTextBox. + /// + private const uint DescriptionTextId = 0x100003E0u; + + /// + /// Root 1d: the page's own backdrop (0x100003d8, live-DAT- + /// measured 7 authored states) switches per selected template — + /// gmCGProfessionPage::UpdateProfession @ 0x004821b0's per-case + /// eax_2->SetState(...) calls, keyed by ChargenTemplate + /// index (0=Custom..6=Soldier), NOT the button-id map above. + /// + private static readonly IReadOnlyDictionary BackdropStateByTemplate = + new Dictionary + { + [0u] = 0x1000002Bu, // Custom / Adventurer + [1u] = 0x1000002Cu, // Bow Hunter + [2u] = 0x10000031u, // Swashbuckler + [3u] = 0x1000002Du, // Life Caster + [4u] = 0x1000002Eu, // War Caster + [5u] = 0x1000002Fu, // Wayfarer + [6u] = 0x10000030u, // Soldier + }; + + /// + /// GF-3: per-template description string id — + /// gmCGProfessionPage::UpdateProfession @0x00482203-0048233d's + /// per-case var_a4_1 literal, resolved through + /// UIElement_Text::SetStringInfo (NOT ...WithFont — a single + /// plain string, no per-run palette color). + /// + private static readonly IReadOnlyDictionary DescriptionKeyByTemplate = + new Dictionary + { + [0u] = "ID_CharGen_CustomText", + [1u] = "ID_CharGen_BowText", + [2u] = "ID_CharGen_SwashText", + [3u] = "ID_CharGen_LifeText", + [4u] = "ID_CharGen_WarText", + [5u] = "ID_CharGen_WayText", + [6u] = "ID_CharGen_SoldierText", + }; + private readonly CharacterCreationRuntimeBindings _bindings; private readonly Dictionary _templateButtons = []; private readonly Dictionary _sliders = []; @@ -76,6 +135,8 @@ internal sealed class CharacterCreationProfessionPage : IDisposable private readonly UiButton? _healthValue; private readonly UiButton? _staminaValue; private readonly UiButton? _manaValue; + private readonly UiText? _description; + private readonly UiElement? _backdrop; private bool _disposed; internal CharacterCreationProfessionPage( @@ -118,22 +179,29 @@ internal sealed class CharacterCreationProfessionPage : IDisposable value.OnSubmit = text => SetAttributeFromText(capturedAttribute, text); } + // GF-4b: the name-label child is static per attribute — retail + // writes it exactly once (InitializePage), never on refresh. + if (UiElement.FindDescendant(container, SliderNameRelativeId) is UiButton nameLabel) + nameLabel.Label = AttributeName(attribute); + _sliders[attribute] = new SliderWidgets(lockButton, slider, value); } - // Live-DAT probe (CharacterCreationLiveDatTests): every one of the - // four display containers (0x100003e2..e5) authors as a Button - // whose Type-12 value child (0x100002f1/0x100002f3) is swallowed by - // UiButton.ConsumesDatChildren — the same "consumed child -> use - // the button's own Label" substitution the Skills page's credits - // meter needed (see CharacterCreationSkillsPage's ctor comment). - // Retail's own DynamicCast(0xc) on the CHILD (not the container) - // still stands as ground truth for the container's ROLE; only - // acdream's widget-level addressability differs (register AD-103). + // GF-4a (Campaign CC gate round 1 Batch C): every one of the four + // display buttons (0x100003e2..e5) authors its CAPTION directly as + // its own P0x17 and carries a SEPARATE, media-less Type-12 value + // child (0x100002f1/0x100002f3 — gmCGProfessionPage::InitializePage + // @0x00482f90-0x00483062). DatWidgetFactory.BuildButton now surfaces + // that child as UiButton.ValueLabel, coexisting with the authored + // Label caption — see that method's own doc comment. Retiring the + // prior Label-clobber substitution (register AD-103). _availableValue = UiElement.FindDescendant(pageRoot, 0x100003E2u) as UiButton; _healthValue = UiElement.FindDescendant(pageRoot, 0x100003E3u) as UiButton; _staminaValue = UiElement.FindDescendant(pageRoot, 0x100003E4u) as UiButton; _manaValue = UiElement.FindDescendant(pageRoot, 0x100003E5u) as UiButton; + + _description = UiElement.FindDescendant(pageRoot, DescriptionTextId) as UiText; + _backdrop = UiElement.FindDescendant(pageRoot, 0x100003D8u); } internal void Refresh( @@ -173,6 +241,23 @@ internal sealed class CharacterCreationProfessionPage : IDisposable SetDisplay(_healthValue, endurance / 2); SetDisplay(_staminaValue, endurance); SetDisplay(_manaValue, snapshot.Attributes.Self); + + // Root 1d: backdrop art per selected template. + if (_backdrop is IUiDatStateful backdropStateful + && BackdropStateByTemplate.TryGetValue(snapshot.Template, out uint backdropState)) + { + backdropStateful.TrySetRetailState(backdropState); + } + + // GF-3: description textbox — one plain segment (SetStringInfo, + // not ...WithFont), so a single DefaultColor run. + if (_description is not null + && DescriptionKeyByTemplate.TryGetValue(snapshot.Template, out string? key)) + { + string? text = _bindings.ResolveText?.Invoke(key); + var segments = new[] { new DatRichText.Segment(text, _description.DefaultColor) }; + _description.LinesProvider = () => DatRichText.Compose(_description, segments); + } } internal void Randomize(RuntimeCharacterCreationSnapshot snapshot) @@ -205,9 +290,27 @@ internal sealed class CharacterCreationProfessionPage : IDisposable { if (display is null) return; - display.Label = value.ToString(CultureInfo.InvariantCulture); + // GF-4a: the button's OWN P0x17 caption ("Attribute Credits" etc.) + // stays in Label; the live number goes in the coexisting value + // slot DatWidgetFactory.BuildButton surfaced from the button's + // media-less Type-12 child. + display.ValueLabel = value.ToString(CultureInfo.InvariantCulture); } + /// Ports CharGenState::GetAttributeName @ 0x005C3A20 + /// verbatim — retail hardcodes these six literals directly (not a + /// DAT/localization lookup), so this port does too. + private static string AttributeName(ChargenAttributeId id) => id switch + { + ChargenAttributeId.Strength => "Strength", + ChargenAttributeId.Endurance => "Endurance", + ChargenAttributeId.Quickness => "Quickness", + ChargenAttributeId.Coordination => "Coordination", + ChargenAttributeId.Focus => "Focus", + ChargenAttributeId.Self => "Self", + _ => string.Empty, + }; + private void SelectTemplate(uint templateIndex) { if (_disposed) diff --git a/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs index 1ab988c3..2bb022a8 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs @@ -126,10 +126,13 @@ internal sealed class CharacterCreationSkillsPage : IDisposable // the mechanism our factory already uses to surface a consumed // Type-12 child's text (register AD-103). // - // GF-5 note: this clobbers the button's authored "Available Skill - // Credits" caption (retail's own m_pCreditsMeter is a SEPARATE - // widget from any caption text) — left as-is per the gate-round - // scope (Batch C owns the caption fix). + // GF-4a (Campaign CC gate round 1 Batch C): this used to clobber + // the button's authored "Available Skill Credits" caption (retail's + // own m_pCreditsMeter, 0x100002f3, is a SEPARATE widget from the + // caption text — gmCGSkillsPage::InitializePage @0x00481e1c). + // DatWidgetFactory.BuildButton now surfaces that media-less Type-12 + // child as UiButton.ValueLabel, coexisting with Label — see + // Refresh below. _credits = UiElement.FindDescendant(pageRoot, 0x100003F9u) as UiButton; _infoTitle = UiElement.FindDescendant(pageRoot, 0x100003FBu) as UiText; _infoText = UiElement.FindDescendant(pageRoot, 0x100003FCu) as UiText; @@ -150,7 +153,7 @@ internal sealed class CharacterCreationSkillsPage : IDisposable RefreshRowValues(row, view, snapshot); if (_credits is { } credits) - credits.Label = snapshot.RemainingSkillCredits.ToString(CultureInfo.InvariantCulture); + credits.ValueLabel = snapshot.RemainingSkillCredits.ToString(CultureInfo.InvariantCulture); } private void RebuildRows(IRuntimeCharacterCreationView view, uint heritageId) diff --git a/src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs b/src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs index e296d907..f2b1be7d 100644 --- a/src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs +++ b/src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs @@ -103,9 +103,22 @@ internal sealed class CharacterCreationTownPage : IDisposable if (_description is null) return; + // GF-11a: gmCGTownPage::SetTownString @ 0x0047c1f0 concatenates + // howTo + a compiled "\n\n%s\n" literal format around the town + // text (the ONE composition site in this batch where retail's OWN + // code — not the authored DAT string content — inserts the blank + // line, confirmed via the format string's raw bytes, + // 0x0079c2d2 = u"\n\n%s\n") into ONE plain SetText — no per-run + // font/color argument, unlike Heritage's WithFont calls. The + // string composition itself was already byte-correct before this + // fix; what was missing was routing it through the same + // escape-normalize + word-wrap path every other description box + // needed (a single un-wrapped line meant the town-specific suffix + // rendered past the clipped viewport, which is why switching towns + // looked like the text never changed). string composed = ComposeDescription(snapshot.StartArea, _bindings.ResolveText); - _description.LinesProvider = () => - [new UiText.Line(composed, _description.DefaultColor)]; + var segments = new[] { new DatRichText.Segment(composed, _description.DefaultColor) }; + _description.LinesProvider = () => DatRichText.Compose(_description, segments); } internal void Randomize(IRuntimeCharacterCreationView view) diff --git a/src/AcDream.App/UI/Layout/DatRichText.cs b/src/AcDream.App/UI/Layout/DatRichText.cs new file mode 100644 index 00000000..c55f1476 --- /dev/null +++ b/src/AcDream.App/UI/Layout/DatRichText.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.App.UI.Layout; + +/// +/// Shared multi-segment rich-text composer for the chargen description +/// boxes (Campaign CC gate round 1 Batch C — GF-2/GF-3/GF-11a, and the +/// Summary how-to text). Ports retail's +/// UIElement_Text::SetStringInfoWithFont / +/// AppendStringInfoWithFont @ 0x00469D70 composition model: a text +/// box is built from an ORDERED list of string segments, each carrying its +/// OWN font-color palette index +/// (UIElement_Text::AppendStringInfoWithFont's +/// SetFontColorHelper -> InqProperty(0x1B) array lookup — +/// see ). +/// +/// +/// The description pages used to bypass this entirely: they assigned a raw +/// LinesProvider lambda returning ONE unwrapped +/// per composed string, with no escape-normalize and no word-wrap. Two +/// concrete symptoms this caused: literal two-character "\n" +/// escapes rendered as backslash-n instead of a real line break (the DAT +/// stores that literal escape — DatWidgetFactory.BuildText's own +/// authored-string path already normalizes it for single-element authored +/// captions; this helper reproduces the SAME normalize for +/// runtime-composed multi-segment text), and — for the Town page +/// specifically — an unwrapped single line meant the town-specific SUFFIX +/// of the composed string rendered far outside the box's clipped viewport, +/// so switching towns looked like "the text never changes" even though the +/// underlying string genuinely did (only its INVISIBLE tail differed). +/// +/// +internal static class DatRichText +{ + /// One composed segment: text plus the color it should render + /// in. A null or empty is silently skipped (mirrors + /// retail's own null-string-info no-op guards throughout this text + /// composition family). + public readonly record struct Segment(string? Text, Vector4 Color); + + /// + /// Escape-normalizes and word-wraps every segment (independently, so + /// each segment's wrapped lines keep ITS OWN color), then concatenates + /// the results in order. No separator is inserted between segments — + /// retail's own composition calls concatenate directly + /// (AppendStringInfoWithFont/append_n_chars with no + /// interposed literal), so any blank-line spacing between sections + /// comes from the authored DAT string content itself, not from code + /// here. + /// + public static IReadOnlyList Compose( + UiText target, + IReadOnlyList segments) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(segments); + + var lines = new List(); + float maximumWidth = MathF.Max(1f, target.Width - 2f * target.Padding); + Func measure = target.DatFont is { } font + ? font.MeasureWidth + : static value => value.Length * 8f; + + foreach (Segment segment in segments) + { + if (string.IsNullOrEmpty(segment.Text)) + continue; + + // The installed DAT stores the LITERAL two-character escape + // "\n" (0x5C 0x6E), not a real line break — same normalize + // DatWidgetFactory.BuildText's authored-string path already + // applies for single-element authored captions. + string normalized = segment.Text + .Replace("\\n", "\n") + .Replace("\r", string.Empty); + + foreach (string wrapped in UiText.WrapWords(normalized, measure, maximumWidth)) + lines.Add(new UiText.Line(wrapped, segment.Color)); + } + + return lines; + } + + /// + /// Resolves 's own authored font-color + /// palette (dat property 0x1B) entry at , + /// falling back to when the palette is + /// absent or too short. Mirrors the same fallback shape + /// CharacterStatController.BuildSelectedTitleRuns already uses + /// for its own palette-indexed colors. + /// + public static Vector4 PaletteColor(UiText target, int index, Vector4 fallback) => + index >= 0 && index < target.FontColorPalette.Count + ? target.FontColorPalette[index] + : fallback; +} diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index bf4df604..a2e4b1ea 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -933,6 +933,39 @@ public static class DatWidgetFactory ElementReader.BuildPerStateColorMap(labelInfo, 0x1Bu), ElementReader.BuildPerStateBoolMap(labelInfo, 0x21u)); + // GF-4a (Campaign CC gate round 1 Batch C): retail's chargen + // display buttons author the caption directly as THEIR OWN P0x17 + // (so `label` above resolved from `info` itself, not a lifted + // child) AND carry a SEPARATE, media-less Type-12 child for the + // live value (gmCGProfessionPage::InitializePage + // @0x00482f90-0x00483062, gmCGSkillsPage::InitializePage + // @0x00481e1c — live-DAT-measured: exactly one Type-12 child, zero + // StateMedia entries). Gated tightly to that exact shape so this + // stays a no-op for every other button (a lifted-caption button + // never reaches here with labelInfo==info; a button with an icon/ + // face child instead of a value child has no media-less Type-12 + // child to find). + if (ReferenceEquals(labelInfo, info) && label is not null) + { + ElementInfo? valueChild = info.Children.FirstOrDefault( + child => child.Type == 12u && child.StateMedia.Count == 0); + if (valueChild is not null) + { + button.ValueBox = (valueChild.X, valueChild.Y, valueChild.Width, valueChild.Height); + button.ValueFont = valueChild.FontDid != 0u && fontResolve is not null + ? fontResolve(valueChild.FontDid) ?? elementFont + : elementFont; + button.ValueColor = valueChild.FontColor ?? System.Numerics.Vector4.One; + button.ValueAlign = valueChild.HJustify == HJustify.Left + ? UiButton.LabelAlignment.Left + : UiButton.LabelAlignment.Center; + // Seed with whatever the child itself authors (typically + // blank) so an unbound button doesn't draw stray leftover + // text before a controller writes a real value. + button.ValueLabel = ResolveAuthoredString(valueChild, stringResolve); + } + } + return button; } diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index 41146914..2cd171ab 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -179,6 +179,43 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful /// public (float X, float Y, float Width, float Height)? LabelBox { get; set; } + /// + /// GF-4a (Campaign CC gate round 1 Batch C): optional secondary VALUE + /// text, coexisting with (the authored CAPTION). + /// Retail's chargen display buttons (Attribute/Skill Credits, Health, + /// Stamina, Mana — 0x100003e2-e5, 0x100003f9) author the + /// caption directly as this element's own dat property 0x17 + /// AND carry a SEPARATE, media-less Type-12 child for the live value + /// (gmCGProfessionPage::InitializePage @0x00482f90-0x00483062, + /// gmCGSkillsPage::InitializePage @0x00481e1c) — + /// consumes ALL of its dat children + /// (), which used to mean a page + /// controller had nowhere faithful to put the value except + /// overwriting itself, destroying the caption. + /// now surfaces that + /// child's geometry/font/color here instead. Null (default) draws + /// nothing extra — every pre-existing button that only ever wrote + /// is unaffected. + /// + public string? ValueLabel { get; set; } + + /// Dat font for . + public UiDatFont? ValueFont { get; set; } + + /// Color for (default white). + public Vector4 ValueColor { get; set; } = Vector4.One; + + /// Authored rectangle for , LOCAL to + /// this button — the lifted value child's own rect + /// ( sets this). Null + /// (no value child found) means is never set + /// either, so this is never read in that case. + public (float X, float Y, float Width, float Height)? ValueBox { get; set; } + + /// Horizontal alignment of within + /// — the lifted child's own authored justify. + public LabelAlignment ValueAlign { get; set; } = LabelAlignment.Center; + /// Label horizontal alignment options. public enum LabelAlignment { Center, Left } @@ -453,6 +490,19 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful ctx.DrawStringDat(lf, label, tx, ty, LabelColor, Outline, OutlineColor); } + if (ValueLabel is { Length: > 0 } value && ValueFont is { } vf) + { + float boxX = ValueBox?.X ?? 0f; + float boxY = ValueBox?.Y ?? 0f; + float boxWidth = ValueBox?.Width ?? Width; + float boxHeight = ValueBox?.Height ?? Height; + float vx = ValueAlign == LabelAlignment.Left + ? boxX + LabelOffsetX + : boxX + (boxWidth - vf.MeasureWidth(value)) * 0.5f; + float vy = boxY + (boxHeight - vf.LineHeight) * 0.5f; + ctx.DrawStringDat(vf, value, vx, vy, ValueColor, Outline, OutlineColor); + } + uint dragSprite = _itemDragAcceptance switch { ItemDragAcceptance.Accept => ItemDragAcceptSprite, diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs index 88cb63ff..a9664b8a 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationLiveDatTests.cs @@ -1053,6 +1053,160 @@ public sealed class CharacterCreationLiveDatTests Assert.True(okButton.Width > 0f && okButton.Height > 0f); } + /// + /// Campaign CC gate round 1 Batch C (GF-4a). Live-DAT-measured: each of + /// the four Profession display buttons (avail/health/stamina/mana + /// credits) and the Skills credits button author the CAPTION directly + /// as their OWN P0x17 property and carry exactly ONE Type-12 child with + /// NO state media of its own — the live VALUE slot + /// (gmCGProfessionPage::InitializePage @0x00482f90-0x00483062, + /// gmCGSkillsPage::InitializePage @0x00481e1c). Pins the shape + /// 's ValueLabel + /// detection depends on. + /// + [InstalledDatFact] + public void ProfessionAndSkillsDisplayButtons_OwnCaptionPlusOneMediaLessValueChild() + { + 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)); + + (uint Button, uint ValueChild)[] shapes = + [ + (0x100003E2u, 0x100002F1u), // Profession available attribute credits + (0x100003E3u, 0x100002F3u), // Profession health + (0x100003E4u, 0x100002F3u), // Profession stamina + (0x100003E5u, 0x100002F3u), // Profession mana + (0x100003F9u, 0x100002F3u), // Skills credits + ]; + foreach ((uint buttonId, uint valueChildId) in shapes) + { + ElementInfo button = Assert.IsType(FindInfo(rootInfo, buttonId)); + Assert.Equal(1u, button.Type); + Assert.True( + button.TryGetEffectiveProperty(0x17u, out UiPropertyValue caption) + && caption.Kind == UiPropertyKind.StringInfo, + $"button 0x{buttonId:X8} must author its own P0x17 caption."); + ElementInfo singleChild = Assert.Single(button.Children); + Assert.Equal(valueChildId, singleChild.Id); + Assert.Equal(12u, singleChild.Type); + Assert.Empty(singleChild.StateMedia); + } + } + + /// + /// GF-4b: the Profession page's six slider containers each carry a + /// name-label CHILD at the SAME relative id (0x100002ed) — + /// live-DAT-measured as Type 1 (UIElement_Button), matching + /// retail's own declared pointer type + /// (class UIElement_Button* m_pHairSpin-shaped fields + /// throughout gmCGAppearancePage/gmCGProfessionPage that + /// still receive UIElement_Text::SetText calls — retail's + /// UIElement_Button is DynamicCast-compatible with + /// UIElement_Text (id 0xc), i.e. buttons carry their own + /// text-rendering capability). acdream's UiButton.Label is that + /// exact capability, so this element resolves as + /// in our port too, not . + /// + [InstalledDatFact] + public void ProfessionPage_SliderContainers_HaveNameLabelButtonChild() + { + using var dats = new DatCollection(DatDirectory, DatAccessType.Read); + uint layoutId = RetailDataIdResolver.Resolve( + dats, CharacterCreationUiController.RootEnum, 5u); + ImportedLayout screen = BuildSelected( + dats, layoutId, CharacterCreationUiController.RootElementId); + UiElement professionRoot = Assert.IsAssignableFrom( + screen.FindElement(CharacterCreationUiController.ProfessionPageElementId)); + + foreach (uint containerId in new[] + { + 0x100003E6u, 0x100003E7u, 0x100003E8u, + 0x100003E9u, 0x100003EAu, 0x100003EBu, + }) + { + UiElement container = Assert.IsAssignableFrom( + UiElement.FindDescendant(professionRoot, containerId)); + Assert.IsType(UiElement.FindDescendant(container, 0x100002EDu)); + } + } + + /// + /// Root 1d (Campaign CC gate round 1 Batch C): the Heritage + /// (0x100003be, 13 states) and Profession (0x100003d8, + /// 7 states) backdrops, live-DAT-measured against + /// gmCGHeritagePage::Update's m_pBackground->SetState + /// literals and gmCGProfessionPage::UpdateProfession's + /// per-template eax_2->SetState literals. + /// + [InstalledDatFact] + public void HeritageAndProfessionBackdrops_AuthorEveryRetailState() + { + 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 heritageBackdrop = Assert.IsType(FindInfo(rootInfo, 0x100003BEu)); + uint[] heritageStates = + [ + 0x10000021u, 0x10000022u, 0x10000023u, 0x10000024u, 0x10000058u, + 0x10000059u, 0x1000005Au, 0x1000005Bu, 0x1000005Cu, 0x1000005Du, + 0x1000005Eu, 0x1000005Fu, 0x10000060u, + ]; + foreach (uint stateId in heritageStates) + Assert.True(heritageBackdrop.States.ContainsKey(stateId), $"heritage backdrop missing state 0x{stateId:X8}"); + + ElementInfo professionBackdrop = Assert.IsType(FindInfo(rootInfo, 0x100003D8u)); + uint[] professionStates = + [ + 0x1000002Bu, 0x1000002Cu, 0x1000002Du, + 0x1000002Eu, 0x1000002Fu, 0x10000030u, 0x10000031u, + ]; + foreach (uint stateId in professionStates) + Assert.True(professionBackdrop.States.ContainsKey(stateId), $"profession backdrop missing state 0x{stateId:X8}"); + } + + /// + /// GF-3: the Profession page's description textbox + /// (0x100003e0) resolves as and (Commit-2 + /// scope, pinned here for completeness) carries the SAME eight + /// gold-frame child ids the Town description (0x10000409) and + /// Summary how-to (0x10000404) boxes carry — one shared box + /// template reused across pages. + /// + [InstalledDatFact] + public void DescriptionTextboxes_ShareTheSameGoldFrameChildTemplate() + { + 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)); + + uint[] frameChildIds = + [ + 0x100002DEu, 0x100002DFu, 0x100002E0u, 0x100002E1u, + 0x100000E8u, 0x100002E2u, 0x100002E3u, 0x100000EAu, + ]; + foreach (uint boxId in new[] { 0x100003E0u, 0x10000409u, 0x10000404u }) + { + ElementInfo box = Assert.IsType(FindInfo(rootInfo, boxId)); + Assert.Equal(12u, box.Type); + foreach (uint frameChildId in frameChildIds) + Assert.Contains(box.Children, c => c.Id == frameChildId); + } + // The Summary how-to box additionally carries a linked scrollbar. + ElementInfo summaryHowTo = Assert.IsType(FindInfo(rootInfo, 0x10000404u)); + Assert.Contains(summaryHowTo.Children, c => c.Id == 0x100002E7u); + } + private static void AssertButton(ImportedLayout layout, uint elementId) => Assert.IsType(layout.FindElement(elementId)); diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs index 7d949070..91b98caf 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs @@ -1505,6 +1505,164 @@ public sealed class CharacterCreationUiControllerTests public void RotateCounterClockwise() => RotateCounterClockwiseCalls++; } + // ── Campaign CC gate round 1 Batch C ──────────────────────────────── + + /// + /// GF-2: the composed description routes through the shared rich-text + /// helper — header segments (palette index 1) render in a DIFFERENT + /// color than body segments (index 0), and each segment's own escape + /// sequence is normalized. The fixture's description element carries + /// no authored FontColorPalette, so this also exercises + /// 's fallback (green header / + /// white body). + /// + [Fact] + public void HeritageDescription_ComposesGreenHeaderAndWhiteBodySegments() + { + using var environment = new EnvironmentHarness(); + environment.Runtime.ResolvedStrings["ID_CharGen_Heritage_StartingSkills_Header"] = "Trained Starting Skills:"; + environment.Runtime.ResolvedStrings["ID_CharGen_Heritage_StartingSkills"] = "Line one\\nLine two"; + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + BumpRevisionAndTick(environment); + + UiText description = Assert.IsType(environment.Screen.FindElement(0x100003C4u)); + var lines = description.LinesProvider().ToList(); + + Assert.Contains(lines, l => l.Text == "Trained Starting Skills:" && l.Color == new Vector4(0f, 1f, 0f, 1f)); + // The literal "\n" escape in the body segment must become TWO + // separate lines, not render as a literal backslash-n. + Assert.Contains(lines, l => l.Text == "Line one" && l.Color == Vector4.One); + Assert.Contains(lines, l => l.Text == "Line two" && l.Color == Vector4.One); + Assert.DoesNotContain(lines, l => l.Text.Contains("\\n")); + } + + /// GF-11a: switching towns changes the RENDERED (wrapped) + /// lines, not just an internal string that never becomes visible — + /// the diagnosed root cause of "description does not change" was a + /// single un-wrapped line whose differing suffix rendered past the + /// clipped viewport. + [Fact] + public void TownDescription_ChangesRenderedLinesWhenSwitchingTowns() + { + using var environment = new EnvironmentHarness(); + environment.Runtime.ResolvedStrings["ID_CharGen_TownHowTo"] = "How to pick a town."; + environment.Runtime.ResolvedStrings["ID_CharGen_HoltText"] = "Holtburg is snowy."; + environment.Runtime.ResolvedStrings["ID_CharGen_ShoushiText"] = "Shoushi is sunny."; + environment.Controller.Open(); + environment.TabButton(CharacterCreationUiController.TownTabElementId).OnClick!(); + + environment.Button(0x1000040Du).OnClick!(); // Holtburg + BumpRevisionAndTick(environment); + UiText description = Assert.IsType(environment.Screen.FindElement(0x10000409u)); + string holtburgText = JoinedText(description); + Assert.Contains("Holtburg is snowy.", holtburgText); + + environment.Button(0x1000040Fu).OnClick!(); // Shoushi + BumpRevisionAndTick(environment); + string shoushiText = JoinedText(description); + Assert.Contains("Shoushi is sunny.", shoushiText); + Assert.DoesNotContain("Holtburg is snowy.", shoushiText); + } + + /// GF-3: the Profession page's description textbox + /// (0x100003e0) binds and switches per selected template. + [Fact] + public void ProfessionDescription_BindsAndSwitchesPerTemplate() + { + using var environment = new EnvironmentHarness(); + environment.Runtime.ResolvedStrings["ID_CharGen_CustomText"] = "Custom flexible build."; + environment.Runtime.ResolvedStrings["ID_CharGen_BowText"] = "Bow hunters use ranged attacks."; + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.TabButton(CharacterCreationUiController.ProfessionTabElementId).OnClick!(); + + environment.Button(0x100003DAu).OnClick!(); // Bow Hunter = template 1 + BumpRevisionAndTick(environment); + + UiText description = Assert.IsType(environment.Screen.FindElement(0x100003E0u)); + Assert.Contains("Bow hunters use ranged attacks.", JoinedText(description)); + } + + /// GF-4a: the display buttons' authored caption survives a + /// value write — the whole point of the ValueLabel coexistence + /// mechanism. + [Fact] + public void ProfessionAndSkillsDisplayButtons_ValueWriteDoesNotClobberLabel() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + + UiButton available = environment.Button(0x100003E2u); + available.Label = "Attribute Credits"; // fixture authors no P0x17; simulate it + UiButton credits = environment.Button(0x100003F9u); + credits.Label = "Available Skill Credits"; + + environment.TabButton(CharacterCreationUiController.ProfessionTabElementId).OnClick!(); + BumpRevisionAndTick(environment); + Assert.Equal("Attribute Credits", available.Label); + // The fixture's default snapshot (BuildOptions' companion default) + // carries RemainingAttributeCredits=66 — an exact, non-vacuous + // pin, not just "some value got written somewhere". + Assert.Equal("66", available.ValueLabel); + + environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!(); + BumpRevisionAndTick(environment); + Assert.Equal("Available Skill Credits", credits.Label); + Assert.Equal("50", credits.ValueLabel); // RemainingSkillCredits=50 + } + + /// GF-6/AP-218: the Appearance page's Hair/Eyes/Skin spins + /// show a heritage-flavored STATIC caption, never a numeric ordinal — + /// and switch to the Gearknight/Olthoi variant per heritage. + [Fact] + public void AppearanceSpinCaptions_ArePartNames_NotOrdinals_AndVaryByHeritage() + { + using var environment = new EnvironmentHarness(); + environment.Runtime.ResolvedStrings["ID_CharGen_HairStyle"] = "Hair Style"; + environment.Runtime.ResolvedStrings["ID_CharGen_Eyes"] = "Eyes"; + environment.Runtime.ResolvedStrings["ID_CharGen_GearText_HairButton"] = "Gear Hair"; + environment.Controller.Open(); + environment.Runtime.SelectHeritageDirect(AluvianId); + environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!(); + BumpRevisionAndTick(environment); + + UiButton hairSpin = environment.Button(CharacterCreationAppearancePage.HairSpinId); + Assert.Equal("Hair Style", hairSpin.Label); + Assert.DoesNotContain(hairSpin.Label, new[] { "1", "2", "-" }); + + environment.Runtime.SelectHeritageDirect((uint)ChargenHeritageGroup.Gearknight); + BumpRevisionAndTick(environment); + Assert.Equal("Gear Hair", hairSpin.Label); + } + + /// Root 1d: the Heritage and Profession backdrops switch + /// state per selection. + [Fact] + public void HeritageAndProfessionBackdrops_SwitchStatePerSelection() + { + using var environment = new EnvironmentHarness(); + environment.Controller.Open(); + + var heritageBackdrop = Assert.IsAssignableFrom( + environment.Screen.FindElement(0x100003BEu)); + environment.Runtime.SelectHeritageDirect(AluvianId); + BumpRevisionAndTick(environment); + Assert.Equal(0x10000021u, heritageBackdrop.ActiveRetailStateId); + + environment.Runtime.SelectHeritageDirect((uint)ChargenHeritageGroup.Gharundim); + BumpRevisionAndTick(environment); + Assert.Equal(0x10000022u, heritageBackdrop.ActiveRetailStateId); + + environment.TabButton(CharacterCreationUiController.ProfessionTabElementId).OnClick!(); + var professionBackdrop = Assert.IsAssignableFrom( + environment.Screen.FindElement(0x100003D8u)); + environment.Button(0x100003DAu).OnClick!(); // Bow Hunter = template 1 + BumpRevisionAndTick(environment); + Assert.Equal(0x1000002Cu, professionBackdrop.ActiveRetailStateId); + } + private static void BumpRevisionAndTick(EnvironmentHarness environment) { RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot; @@ -2113,6 +2271,20 @@ public sealed class CharacterCreationUiControllerTests page.Children.Add(ButtonInfo(0x100005C7u)); // Olthoi page.Children.Add(ButtonInfo(0x100005F1u)); // Lugian (F3 quirk: no tab-restore/hide) page.Children.Add(TextInfo(0x100003C4u)); + + // Root 1d: the backdrop, live-DAT-measured 13 authored states + // (see CharacterCreationHeritagePage.BackdropStateByHeritage). + var backdrop = ContainerInfo(0x100003BEu); + foreach (uint stateId in new[] + { + 0x10000021u, 0x10000022u, 0x10000023u, 0x10000024u, 0x10000058u, + 0x10000059u, 0x1000005Au, 0x1000005Bu, 0x1000005Cu, 0x1000005Du, + 0x1000005Eu, 0x1000005Fu, 0x10000060u, + }) + { + backdrop.States[stateId] = new UiStateInfo { Id = stateId, Name = $"State_{stateId:X8}" }; + } + page.Children.Add(backdrop); return page; } @@ -2138,6 +2310,21 @@ public sealed class CharacterCreationUiControllerTests page.Children.Add(ButtonInfo(0x100003E3u)); // Health page.Children.Add(ButtonInfo(0x100003E4u)); // Stamina page.Children.Add(ButtonInfo(0x100003E5u)); // Mana + + page.Children.Add(TextInfo(0x100003E0u)); // GF-3: description textbox + + // Root 1d: the backdrop, live-DAT-measured 7 authored states (see + // CharacterCreationProfessionPage.BackdropStateByTemplate). + var backdrop = ContainerInfo(0x100003D8u); + foreach (uint stateId in new[] + { + 0x1000002Bu, 0x1000002Cu, 0x1000002Du, + 0x1000002Eu, 0x1000002Fu, 0x10000030u, 0x10000031u, + }) + { + backdrop.States[stateId] = new UiStateInfo { Id = stateId, Name = $"State_{stateId:X8}" }; + } + page.Children.Add(backdrop); return page; } diff --git a/tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs new file mode 100644 index 00000000..4d94a819 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs @@ -0,0 +1,128 @@ +using System.Numerics; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Campaign CC gate round 1 Batch C: unit tests for the shared +/// escape-normalize + word-wrap + per-segment-color helper feeding +/// GF-2/GF-3/GF-11a and the Summary how-to text (Commit 3). +/// +public class DatRichTextTests +{ + private static readonly Vector4 White = Vector4.One; + private static readonly Vector4 Green = new(0f, 1f, 0f, 1f); + + private static UiText MakeTarget(float width) => + new() { Width = width, Height = 200f }; + + [Fact] + public void Compose_NormalizesLiteralBackslashNEscape() + { + UiText target = MakeTarget(1000f); // wide enough that nothing wraps + var segments = new[] { new DatRichText.Segment("line one\\nline two", White) }; + + var lines = DatRichText.Compose(target, segments); + + Assert.Equal(2, lines.Count); + Assert.Equal("line one", lines[0].Text); + Assert.Equal("line two", lines[1].Text); + } + + [Fact] + public void Compose_WordWrapsToTheTargetWidth() + { + // Bitmap-font-shaped measure: 8px/char, matching BuildText's own + // authored-string fallback measure. + UiText target = MakeTarget(80f); // 10 chars per line at 8px/char + var segments = new[] + { + new DatRichText.Segment("one two three four five six seven eight", White), + }; + + var lines = DatRichText.Compose(target, segments); + + Assert.True(lines.Count > 1, "a long segment must wrap to more than one line"); + foreach (UiText.Line line in lines) + Assert.True(line.Text.Length * 8f <= 80f, $"line '{line.Text}' overflowed the target width"); + } + + [Fact] + public void Compose_EachSegmentKeepsItsOwnColorAcrossItsWrappedLines() + { + UiText target = MakeTarget(1000f); + var segments = new[] + { + new DatRichText.Segment("Header:", Green), + new DatRichText.Segment("Body text.", White), + }; + + var lines = DatRichText.Compose(target, segments); + + Assert.Equal(2, lines.Count); + Assert.Equal(Green, lines[0].Color); + Assert.Equal(White, lines[1].Color); + } + + [Fact] + public void Compose_NullOrEmptySegmentText_IsSkipped() + { + UiText target = MakeTarget(1000f); + var segments = new[] + { + new DatRichText.Segment(null, White), + new DatRichText.Segment(string.Empty, White), + new DatRichText.Segment("real text", White), + }; + + var lines = DatRichText.Compose(target, segments); + + Assert.Single(lines); + Assert.Equal("real text", lines[0].Text); + } + + [Fact] + public void Compose_NoSeparatorInsertedBetweenSegments() + { + // Retail's own composition calls concatenate directly + // (AppendStringInfoWithFont / append_n_chars, no interposed + // literal) — this helper must not invent one either. + UiText target = MakeTarget(1000f); + var segments = new[] + { + new DatRichText.Segment("first", White), + new DatRichText.Segment("second", White), + }; + + var lines = DatRichText.Compose(target, segments); + + // Each segment still wraps independently (so "first"/"second" stay + // on separate output lines, not glued into "firstsecond") — but no + // BLANK line is inserted between them unless the segment's own + // text carried one. + Assert.Equal(2, lines.Count); + Assert.Equal("first", lines[0].Text); + Assert.Equal("second", lines[1].Text); + } + + [Fact] + public void PaletteColor_ReturnsAuthoredPaletteEntry_WhenPresent() + { + UiText target = new() + { + FontColorPalette = [White, Green], + }; + + Assert.Equal(White, DatRichText.PaletteColor(target, 0, Green)); + Assert.Equal(Green, DatRichText.PaletteColor(target, 1, White)); + } + + [Fact] + public void PaletteColor_FallsBack_WhenPaletteTooShortOrMissing() + { + UiText target = new(); // empty palette + + Assert.Equal(Green, DatRichText.PaletteColor(target, 1, Green)); + Assert.Equal(Green, DatRichText.PaletteColor(target, -1, Green)); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index 9c428648..e9066456 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -456,6 +456,88 @@ public class DatWidgetFactoryTests Assert.Equal(36f, button.LabelOffsetX); // face.X(0) + face.Width(32) + 4 } + /// + /// GF-4a (Campaign CC gate round 1 Batch C): retail's chargen display + /// buttons (live-DAT-measured shape) author their CAPTION directly as + /// their own P0x17 AND carry one SEPARATE, media-less Type-12 child for + /// the live value. BuildButton surfaces that child through + /// / + /// instead of dropping it — coexisting with, not clobbering, + /// . + /// + [Fact] + public void BuildButton_OwnCaptionPlusMediaLessTextChild_SurfacesValueSlotWithoutClobberingLabel() + { + uint captionStringId = 111u; + var info = new ElementInfo { Type = 1, Width = 80, Height = 20 }; + info.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId }; + info.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue + { + Kind = UiPropertyKind.StringInfo, + StringInfoValue = new UiStringInfoValue(0, captionStringId, 0, 0, 0, 0), + }; + // The button carries its own background media (authoredFaces stays + // empty — matches the real display buttons, which have their OWN + // frame art, not a lifted face-segment child). + info.StateMedia[""] = (0x06000001u, 1); + + var valueChild = new ElementInfo { Type = 12, X = 5, Y = 2, Width = 60, Height = 16 }; + info.Children.Add(valueChild); + + var button = Assert.IsType(DatWidgetFactory.Create( + info, NoTex, null, + stringResolve: value => value.StringId == captionStringId ? "Attribute Credits" : null)); + + Assert.Equal("Attribute Credits", button.Label); + Assert.Null(button.ValueLabel); // nothing authored on the child itself + Assert.Equal((5f, 2f, 60f, 16f), button.ValueBox); + + // Writing the live value (as CharacterCreationProfessionPage.SetDisplay + // does) must not touch the caption — the whole point of this fix. + button.ValueLabel = "42"; + Assert.Equal("Attribute Credits", button.Label); + Assert.Equal("42", button.ValueLabel); + } + + /// + /// Negative companion: a button whose caption was LIFTED from a + /// distinct Type-12 child (the town-marker shape, + /// !ReferenceEquals(labelInfo, info)) must NOT pick up a + /// ValueBox even if the button happens to have another Type-12 child — + /// the gate is ReferenceEquals(labelInfo, info), own-caption + /// only. + /// + [Fact] + public void BuildButton_LiftedCaption_NeverSurfacesValueSlot() + { + uint stringId = 222u; + var info = new ElementInfo { Type = 1, Width = 106, Height = 80 }; + info.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal" }; + info.States[6u] = new UiStateInfo { Id = 6u, Name = "Highlight" }; + + var caption = new ElementInfo { Type = 12, X = 0, Y = 4, Width = 100, Height = 37 }; + caption.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId }; + caption.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue + { + Kind = UiPropertyKind.StringInfo, + StringInfoValue = new UiStringInfoValue(0, stringId, 0, 0, 0, 0), + }; + info.Children.Add(caption); + + var marker = new ElementInfo { Type = 3, X = 36, Y = 36, Width = 38, Height = 38 }; + marker.StateMedia["Normal"] = (0x06004D60u, 1); + marker.StateMedia["Highlight"] = (0x06004D61u, 1); + info.Children.Add(marker); + + var button = Assert.IsType(DatWidgetFactory.Create( + info, NoTex, null, + stringResolve: value => value.StringId == stringId ? "Holtburg" : null)); + + Assert.Equal("Holtburg", button.Label); + Assert.Null(button.ValueBox); + Assert.Null(button.ValueLabel); + } + // ── Test 5b: Type 11 → UiScrollbar ────────────────────────────────────── [Fact]