diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 04cc21cb..c10d51cb 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -64,7 +64,8 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 84 active rows (AD-110 filed 2026-08-17 at the entry/exit presentation round — the in-world logoff's single confirmed-echo handoff edge versus retail's two independent ExecuteLogOff/CharacterList edges, and the Tunnel-hold tail; AD-74 RETIRED 2026-08-17 at the same round — the Exit to Character Selection "behaves as Exit Game" adaptation is deleted: the confirmed grounded exit now runs the REAL retail flow (0xF653 request, server LogOut motion, 3 s hold, reverse wormhole, return to the live-connection character-select screen via LiveSessionController.CompleteCharacterLogOff), and the previously-missing indicator-bar grounded gate now runs retail's shared three-way branch; AD-109 filed 2026-08-17 at the entry/exit presentation round — the click-armed login tunnel: the wormhole presentation + enter cue now begin at the character-select Enter click instead of retail's black CreatePlayer wait, USER-DIRECTED; AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. 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) +## 2. Adaptation (AD) — 85 active rows (AD-110 filed 2026-08-17 at the entry/exit presentation round — the in-world logoff's single confirmed-echo handoff edge versus retail's two independent ExecuteLogOff/CharacterList edges, and the Tunnel-hold tail; AD-74 RETIRED 2026-08-17 at the same round — the Exit to Character Selection "behaves as Exit Game" adaptation is deleted: the confirmed grounded exit now runs the REAL retail flow (0xF653 request, server LogOut motion, 3 s hold, reverse wormhole, return to the live-connection character-select screen via LiveSessionController.CompleteCharacterLogOff), and the previously-missing indicator-bar grounded gate now runs retail's shared three-way branch; AD-109 filed 2026-08-17 at the entry/exit presentation round — the click-armed login tunnel: the wormhole presentation + enter cue now begin at the character-select Enter click instead of retail's black CreatePlayer wait, USER-DIRECTED; AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. 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; AD-111 (renumbered from a parallel-round AD-109 collision) filed 2026-08-17 at the systemic escape-normalization round — the appraisal report's wire-domain literal- +-to-line-break shaping, which retail's `ItemExamineUI::AddItemInfo @0x004AC050` does not do (wire text appends verbatim; the escape decode retail runs at `StringInfo` resolution now lives at our string source, `DatStringResolver` → `RetailStringEscapes`); AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. 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 @@ -107,6 +108,9 @@ readiness/requeue adaptation. See | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| AD-111 | **Filed 2026-08-17 at the systemic escape-normalization round (commit 967b9c57).** The appraisal report's WIRE-string shaping (`ItemAppraisalTextLayout.Shape`) converts a literal two-character ` +` in server-sent fragment text (long description, use text) into a real line break. Retail does NOT: `ItemExamineUI::AddItemInfo @ 0x004AC050` hands wire text straight to `UIElement_Text::AppendTextWithFont` with no `StringTableMetaLanguage::UnescapeString` pass (that decode belongs to `StringInfo` resolution — DAT/authored strings — which the same round ported to `DatStringResolver`/`RetailStringEscapes` as the single source decode), so retail renders a wire backslash-n literally. Pre-existing behavior documented as wire-domain at the same round (it shipped inside the user-accepted Slice-3 assessment surface); the sibling inscription path (`IndicatorDetailText.Shape`) was returned to retail-verbatim in the same commit. | `src/AcDream.App/UI/Layout/ItemAppraisalReport.cs` (`ItemAppraisalTextLayout.Shape`'s domain-commented replace) | Accommodates literal " +" sequences appearing in ACE database strings; server strings carrying REAL line-break characters flow through the same split either way, so the replace only ever fires on content retail would render with a visible backslash-n. | A wire string legitimately containing the two characters backslash+n (a file path, ASCII art in a description) renders with a spurious line break where retail shows it literally. | `ItemExamineUI::AddItemInfo @ 0x004AC050`; `UIElement_Text::AppendTextWithFont` (direct append, no unescape); `StringTableMetaLanguage::UnescapeString @ 0x0067BDC0` (the decode retail applies ONLY at StringInfo resolution) | | AD-108 | **Filed 2026-08-17 at the night-round review fix round (F9); MECHANISM REPLACED the same day at the overnight round's final fix, after live verification found the row's original standalone re-import resolving NOTHING.** Retail authors the Map tab's player-location and house-location icons (`0x100001ED`/`0x100001EE`) as ordinary nested dat children of `m_pMap` (`0x100001EC`) — itself a Type-1 `UIElement_Button`, the GM click-to-teleport feature `gmMapUI::ListenToElementMessage @0x004a2350` idMessage `0x1c` reads — and `gmMapUI::PostInit @0x004a1c70` resolves them as ordinary live child elements. acdream's `UiButton.ConsumesDatChildren` swallows a button's dat children as skin/label parts during the normal import walk, so the two icons never exist in the built tree and `UiElement.FindDescendant` against the page root returns null for them. **The shipped adaptation:** `MapPageController.Bind` finds each icon's `ElementInfo` under `m_pMap`'s own ALREADY-RESOLVED info subtree — `pageInfo`, a subtree of the panel-slot resolve `ImportInfos(dats, 0x2100006E, 0x1000018C)`, the ONLY pathway that materializes these infos at all — and BUILDS it through the new `Bindings.IconBuilder` seam (production: `LayoutImporter.Build(info, ...)` under the DAT lock — the build half of `RowTemplateResolver`'s shape, no import half), attaching the result as a runtime child of the built `m_pMap`. Live-DAT-pinned structural facts (`MapHousePanelLiveDatMountTests`, the pin the original gap proved missing): a cold `ImportInfos(dats, hostLayoutId, iconElementId)` returns null for BOTH icons — its `FindDesc` walks the LayoutDesc's raw top-level `Elements` table (exactly ONE entry for host layout `0x2100006E`) recursing through `ElementDesc.Children`, a purely structural walk with no tab-page/state-descriptor resolution — while the full panel-slot resolve materializes both icons nested under `m_pMap` with real authored extents. (The town-hotspot template `0x100001F0` is different in kind: a genuine standalone catalog entry addressable by `(templateLayoutId, templateElementId)`, whose import-then-build resolution is correct and unchanged.) `ResolveSwallowedIcon` also prefers an icon the normal build walk DID produce (`FindDescendant` under `m_pMap` first), so a future `ConsumesDatChildren` policy change cannot leave a second, permanently-static copy behind the live marker. **Second mechanism half (found by this fix's own F1 live verification):** both icons are detached from the per-frame authored layout pass (`PrepareIcon` sets `Anchors = AnchorEdges.None`, which also clears any imported `LayoutPolicy`) because `PlaceMarker` owns their position outright (retail's `gmMapUI::Update` re-places both markers every tick, and retail's `UpdateForParentSizeChange` runs only on actual parent resize) — acdream re-runs `ApplyAnchor` per frame, and the icon's compatibility anchor had captured the authored `(0,0)` rect while the panel window was still hidden, re-asserting it every frame over PlaceMarker's writes: a live-observed visible green ring pinned to `m_pMap`'s top-left corner regardless of player position, with only the coordinate text correct. | `src/AcDream.App/UI/Layout/MapPageController.cs` (`Bind`'s two `ResolveSwallowedIcon` call sites, `ResolveSwallowedIcon`'s body, `Bindings.IconBuilder`); `src/AcDream.App/UI/RetailUiRuntime.cs` (`MountMapHousePanel`'s `BuildSwallowedIcon`) | The rebuilt icons carry their authored ids and extents — `PlaceMarkerOnMap`'s centering divides the icon's own Width/Height, and the pin test asserts non-degenerate extents on the installed DAT. Their authored local position is irrelevant: `PlaceMarker` overwrites `Left`/`Top` on every 5 s `Refresh`, and each icon starts hidden until the first refresh decides real visibility — same net presentation as retail's find-the-child. | A future DAT regeneration that reauthors the icons OUTSIDE `m_pMap`'s subtree would leave `FindInfo(mapInfo, iconId)` null again — the same silent "[D.2b] … not authored under m_pMap" log-and-hide failure mode this row's original defect had, but now caught by `MapHousePanelLiveDatMountTests` failing on the next suite run instead of only at a connected gate. | `gmMapUI::PostInit @0x004a1c70` (child resolution); `gmMapUI::ListenToElementMessage @0x004a2350` idMessage `0x1c` (confirms `m_pMap` IS a button, not a passive container); `gmMapUI::PlaceMarkerOnMap @0x004a18b0` (the marker math consuming the rebuilt icons) | | AD-106 | **Filed 2026-08-16 at #409 (client-wide retail tooltip system).** Retail's tooltip popup is a separate always-on-top presentation surface — `UIElementManager::StartTooltip @0x00459700` positions and latches it into `m_pTooltipElement`, drawn independently of the ordinary `UIElement` sibling tree (the SAME class of separation the AP-229 register row already establishes for retail's dialogs vs acdream's flat sibling list under one `Host.Root`). `RetailTooltipPresenter` instead mounts the popup as an ordinary `UiRoot` child sibling (`_host.AddChild(root)`) and keeps it topmost by calling `BringToFront` from its OWN `Tick()`, which `RetailUiRuntime.Tick` schedules AFTER both `RetailDialogFactory.Tick()` and `Host.Tick()` in the same frame — guaranteeing the tooltip wins whatever z-order race those two just ran, every frame, regardless of which dialog/screen last called its own `BringToFront`. | `src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs` (`Tick`, `OnTooltipShow`'s `AddChild`/`BringToFront`); `src/AcDream.App/UI/RetailUiRuntime.cs` (`Tick`'s three-call ordering, `MountTooltipPresenter`) | Reproduces the one observable invariant a user can check (tooltips always draw on top of dialogs and screens) without porting retail's literal separate-layer architecture (no second draw pass, no dedicated presentation root) — the SAME tradeoff AP-229 already accepted for dialogs, extended one layer further. The ordering is enforced structurally (three sequential calls in one method), not by convention, so it cannot silently regress from an unrelated edit reordering unrelated `Tick` calls elsewhere. **F10 correction (2026-08-16 review round), two honest additions:** (1) the guarantee is versus dialogs/screens ONLY — `UiRoot.DrawCore`'s own second pass (`ctx.BeginOverlayLayer(); DrawOverlays(ctx); DrawDragGhost(ctx);`) routes open dropdown/menu popups and the drag ghost to a renderer overlay layer that paints over the WHOLE sibling tree unconditionally, so both still paint above a shown tooltip regardless of any `BringToFront` ordering — no z-order fix in the sibling tree can reach that layer. (2) counting the full chain by its own actual participants (not just the three calls local to `RetailUiRuntime.Tick`'s tooltip-adjacent lines), the per-tick `BringToFront` ratchet has FOUR rungs in frame order: `CharacterManagementUiController.Tick`, `CharacterCreationUiController.Tick` (both named in `RetailDialogFactory`'s own GF-15 doc comment as the screens it re-asserts over), `RetailDialogFactory.Tick`, then `RetailTooltipPresenter.Tick`. Four independent per-tick self-reraises stacked by tick ORDER is a design smell — a correct z-order model would need at most one authoritative comparison, not N racing assertions — but is bounded and enumerable in practice (no unbounded surface list, the order is fixed source, not runtime-discovered) so it is left as observed rather than restructured this round. | A FUTURE always-on-top UI surface that calls its own unconditional per-tick `BringToFront` AFTER `TooltipPresenter?.Tick()` in `RetailUiRuntime.Tick`'s ordering could bury a currently-shown tooltip — the exact failure class AP-229 already named for dialogs-vs-screens, now with four layers instead of two. | `UIElementManager::StartTooltip @0x00459700` (`m_pTooltipElement` ownership); AP-229's own dialog/screen precedent | | AD-73 | Filed 2026-08-11 at the Campaign OP OP2 rework (fix round after a double REJECT). `UiTabPanel` (dat Type 8, formerly `UiTabControl`) does NOT perform retail's automatic tab-table wiring / default-page activation at construction. Retail `UIElement_Panel::SetupTabPageHash @0x0046C2E0` + `::Update @0x0046BD00` unconditionally activate the authored default page for ANY instance that carries a tab table. `UiTabPanel` instead stays DORMANT — no click binding, no page-visibility flip, no tab Open/Closed write — until a controller explicitly calls `ActivateTabBehavior()`. | `src/AcDream.App/UI/UiTabPanel.cs` (`ActivateTabBehavior`); factory site `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (Type-8 arm) | Four already-shipped Type-8 hosts author a tab table today — character sheet root `0x10000227`, spellbook root `0x100002A8`, and vendor `0x100000B8` already implement this exact switching in their own C# controllers (`CharacterStatController`/`SpellbookWindowController`/`VendorUiController`); activating `UiTabPanel`'s own copy unconditionally would double-drive the same page-visibility/tab-state writes those controllers already own. Combat `0x100000A2` has no controller at all and is INTENTIONALLY left inert (its 8 stance pages have no switching UI yet) rather than have `UiTabPanel` silently take ownership. Only newly-authored hosts opt in (Options panel, Campaign OP slice OP3+; Configure Keyboard, OP8). This is what let the unconditional Type-8 factory mapping become safe after the OP2 REJECT (`docs/research/2026-08-11-op2-review-blast.md`, `docs/research/2026-08-11-op2-review-mechanism.md`). | A future panel that authors a Type-8 tab table but never gets a controller call to `ActivateTabBehavior()` renders with every tab button at its authored default (Closed) and every page slot at its default `Visible=true` — i.e. every page overlapping, no single active page — instead of retail's exactly-one-visible-page behavior. This is silent unless the diagnostic `UnresolvedEntries`/`BehaviorActive` surface is checked; a controller author who forgets the activation call will see a visually broken tab host, not a crash. | `UIElement_Panel::SetupTabPageHash @0x0046C2E0`; `UIElement_Panel::Update @0x0046BD00`; `UIElement_Panel::OpenTab @0x0046BE20`. ADDENDUM (2026-08-11, re-review closure): `UiTemplateListBox` additionally reports `ConsumesDatChildren = true` where the pre-rework fallback did not — inert against every shipped layout because no Type-5 element in any of the 32 fixtures authors children (now conformance-PINNED in `OP2ReworkBlastRadiusConformanceTests`, so an authored child appearing in a future DAT regeneration fails the build instead of silently vanishing) | diff --git a/src/AcDream.App/UI/Layout/DatRichText.cs b/src/AcDream.App/UI/Layout/DatRichText.cs index 60b10bc3..b1b8e333 100644 --- a/src/AcDream.App/UI/Layout/DatRichText.cs +++ b/src/AcDream.App/UI/Layout/DatRichText.cs @@ -19,17 +19,16 @@ namespace AcDream.App.UI.Layout; /// /// 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). +/// per composed string, with no word-wrap. Historical symptom (Batch C): +/// for the Town page 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). Escape decoding (the DAT's literal two-character "\n", +/// Batch C's other symptom) has since moved to the string source +/// (, +/// the 2026-08-17 systemic round) — segments reach this composer with real +/// line breaks already in place. /// /// internal static class DatRichText @@ -41,7 +40,7 @@ internal static class DatRichText public readonly record struct Segment(string? Text, Vector4 Color); /// - /// Escape-normalizes and word-wraps every segment (independently, so + /// 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 @@ -76,15 +75,12 @@ internal static class DatRichText 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)) + // Escape decoding (the DAT's literal two-character "\n") happens + // at the string source (DatStringResolver → RetailStringEscapes, + // 2026-08-17 systemic round — retail's own placement), so + // segments arrive with real line breaks; WrapWords preserves + // them and drops any stray CR itself. + foreach (string wrapped in UiText.WrapWords(segment.Text, measure, maximumWidth)) lines.Add(new UiText.Line(wrapped, segment.Color)); } diff --git a/src/AcDream.App/UI/Layout/DatStringResolver.cs b/src/AcDream.App/UI/Layout/DatStringResolver.cs index 4932c47a..70f3df7f 100644 --- a/src/AcDream.App/UI/Layout/DatStringResolver.cs +++ b/src/AcDream.App/UI/Layout/DatStringResolver.cs @@ -9,9 +9,23 @@ namespace AcDream.App.UI.Layout; /// The caller owns synchronization around reads. /// /// +/// /// Retail reference: StringInfo::GetString and /// compute_str_hash @ 0x00413110. A StringInfo's token selects one /// localized string variant; ordinary UI labels use token zero. +/// +/// +/// Every resolution decodes the DAT's two-character escapes +/// (\n, \t, \r, \q, and the metalanguage +/// self-escapes) HERE, at the source — retail's own placement: every public +/// StringInfo resolution ends in +/// StringTableMetaLanguage::UnescapeString @ 0x0067BDC0 +/// (StringInfo::InqString @ 0x0042E490, +/// StringInfo::GetLiteralValue @ 0x0042CA50). Consumers receive +/// already-decoded text and must not re-decode — see +/// ' remarks for the double-decode hazard +/// (the 2026-08-17 systemic round that retired the per-consumer copies). +/// /// public sealed class DatStringResolver { @@ -41,7 +55,9 @@ public sealed class DatStringResolver return null; int index = token >= 0 && token < entry.Strings.Count ? token : 0; - return entry.Strings[index].Value; + // StringInfo::InqString @ 0x0042E490's unconditional tail: the stored + // string is escaped; the resolved string is decoded. + return RetailStringEscapes.Unescape(entry.Strings[index].Value); } /// Returns every literal token for one retail StringInfo entry. @@ -57,7 +73,9 @@ public sealed class DatStringResolver return table is not null && table.Strings.TryGetValue(stringId, out var entry) && entry.Strings.Count != 0 - ? entry.Strings.Select(value => value.Value).ToArray() + ? entry.Strings + .Select(value => RetailStringEscapes.Unescape(value.Value)) + .ToArray() : null; } @@ -105,14 +123,21 @@ public sealed class DatStringResolver { composed.Append(entry.Strings[i].Value); // Variables are stored as the pre-computed name hashes (the same - // compute_str_hash space PlayerVariable lives in). + // compute_str_hash space PlayerVariable lives in). Each value is + // escaped on insert — retail's AddVariable_String @ 0x0042E6C0 + // stores every variable through SetLiteralValue(escape=1) + // @ 0x0042C980 → EscapeString — so the final whole-string + // unescape below returns variable content verbatim while + // decoding the authored fragments' escapes. if (i < entry.Variables.Count && variables.TryGetValue(entry.Variables[i], out string? value)) { - composed.Append(value); + composed.Append(RetailStringEscapes.Escape(value)); } } - return composed.ToString(); + // StringInfo::InqString @ 0x0042E490's unconditional tail, same as + // Resolve above: composed text decodes its escapes at the source. + return RetailStringEscapes.Unescape(composed.ToString()); } /// diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 29b71eeb..9713c159 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -856,9 +856,11 @@ public static class DatWidgetFactory { // 2026-08-13 social gate: authored strings can carry embedded // newlines (the fellowship empty-state is three sentences over - // '\n's). Gate round 2: the DAT stores the LITERAL two-character - // escape "\n" (0x5C 0x6E — probe-verified: the dump printed - // backslash-n, not a line break), so normalize the escape first. + // '\n's). The DAT stores those as the LITERAL two-character + // escape "\n" (0x5C 0x6E — probe-verified), decoded at the + // string SOURCE since the 2026-08-17 systemic round + // (DatStringResolver → RetailStringEscapes; retail's own + // placement) — `authored` arrives with REAL line breaks here. // Gate round 3: retail additionally WORD-WRAPS each authored line // within the element extent (its GlyphList draw — the same wrap // the confirmation dialog view already uses), so a multiline @@ -868,10 +870,7 @@ public static class DatWidgetFactory // re-wrapping them is a client-wide behavior change no gate has // asked for). Providers re-read DefaultColor/width/font per call // (NOT captured eagerly) so state-driven changes keep tracking. - string normalized = authored - .Replace("\\n", "\n") - .Replace("\r", string.Empty); - if (normalized.Contains('\n')) + if (authored.Contains('\n')) { float cachedWidth = float.NaN; UiDatFont? cachedFont = null; @@ -897,7 +896,7 @@ public static class DatWidgetFactory ? font.MeasureWidth : static value => value.Length * 8f; cachedLines = [.. UiText - .WrapWords(normalized, measure, maximumWidth) + .WrapWords(authored, measure, maximumWidth) .Select(line => new UiText.Line(line, t.DefaultColor))]; } return cachedLines; @@ -906,7 +905,7 @@ public static class DatWidgetFactory else { t.LinesProvider = () => - [new UiText.Line(normalized, t.DefaultColor)]; + [new UiText.Line(authored, t.DefaultColor)]; } } @@ -921,7 +920,7 @@ public static class DatWidgetFactory || !state.Properties.Values.TryGetValue(0x17u, out var stateCaption) || stateCaption.Kind != UiPropertyKind.StringInfo) continue; - if (NormalizeEscapes(stringResolve?.Invoke(stateCaption.StringInfoValue)) + if (stringResolve?.Invoke(stateCaption.StringInfoValue) is { Length: > 0 } text) (stateStrings ??= new Dictionary())[stateId] = text; } @@ -1220,6 +1219,20 @@ public static class DatWidgetFactory .OrderBy(child => child.ReadOrder) .ToArray(); + /// + /// Resolves the effective authored caption (dat property 0x17) + /// for a widget. Escape decoding is NOT done here: since the 2026-08-17 + /// systemic round the string SOURCE ( → + /// , retail's own placement — every + /// StringInfo resolution ends in + /// StringTableMetaLanguage::UnescapeString @ 0x0067BDC0) hands + /// every consumer already-decoded text. That supersedes R2-2 (Campaign + /// CC gate round 1 Batch E)'s consumer-level normalize, which covered + /// only the P0x17 resolutions in THIS file and missed sibling consumers + /// (the exit-world confirmation dialog, gate round 2) — the exact class + /// of bug source placement closes. Re-decoding here would corrupt an + /// authored \\n (escaped backslash then 'n') into a line break. + /// private static string? ResolveAuthoredString( ElementInfo info, Func? stringResolve) @@ -1228,42 +1241,16 @@ public static class DatWidgetFactory || !info.TryGetEffectiveProperty(0x17u, out var property) || property.Kind != UiPropertyKind.StringInfo) return null; - string? resolved = stringResolve(property.StringInfoValue); - // R2-2 (Campaign CC gate round 1 Batch E): the DAT stores the LITERAL - // two-character escape "\n" (0x5C 0x6E), not a real line break — same - // fact BuildText's own authored-string path already normalized for - // (see that call site's own comment). Centralizing the normalize - // HERE, at the single choke point every P0x17 caption resolution in - // this file goes through (BuildText, BuildButton's own caption AND - // its lifted-child caption, BuildButton's coexisting ValueLabel, - // BuildCheckbox), closes the exact class of bug R2-2 found: a caption - // like the Profession credits button's own "Attribute\n Credits" - // rendered the literal backslash-n because BuildButton never - // normalized while BuildText did. BuildText's own subsequent - // Replace("\\n","\n") is now a harmless no-op (idempotent) — left in - // place rather than removed, since it costs nothing and documents the - // same fact locally. - return NormalizeEscapes(resolved); + return stringResolve(property.StringInfoValue); } - /// - /// R2-2 (Campaign CC gate round 1 Batch E): the shared escape-normalize - /// applies, pulled out so the - /// per-STATE authored-caption loop below (which resolves a state's own - /// 0x17 directly, bypassing the effective-property resolution - /// wraps) gets the SAME normalize - /// instead of a second, easily-forgotten copy. - /// - private static string? NormalizeEscapes(string? raw) => - raw?.Replace("\\n", "\n").Replace("\r", string.Empty); - /// /// #409 (client-wide retail tooltip system): resolves the already- /// extracted (dat property - /// 0x49) through , applying the - /// SAME escape normalization every other authored StringInfo - /// (captions, 0x17) gets at this one choke point. Null when the - /// element authors no tooltip text or no resolver is available. + /// 0x49) through . Arrives + /// escape-decoded from the string source, like every authored + /// StringInfo (see ). Null + /// when the element authors no tooltip text or no resolver is available. /// internal static string? ResolveTooltipText( ElementInfo info, @@ -1271,6 +1258,6 @@ public static class DatWidgetFactory { if (stringResolve is null || info.TooltipText is not { } tooltipText) return null; - return NormalizeEscapes(stringResolve(tooltipText)); + return stringResolve(tooltipText); } } diff --git a/src/AcDream.App/UI/Layout/IndicatorDetailText.cs b/src/AcDream.App/UI/Layout/IndicatorDetailText.cs index baf4b5b2..f47d0af8 100644 --- a/src/AcDream.App/UI/Layout/IndicatorDetailText.cs +++ b/src/AcDream.App/UI/Layout/IndicatorDetailText.cs @@ -23,8 +23,13 @@ internal static class IndicatorDetailText ?? value.Length * 8f; var lines = new List(); - string normalized = text.Replace("\\n", "\n", StringComparison.Ordinal); - foreach (string paragraph in normalized.Split('\n')) + // DAT-resolved bodies (vitae, link status, effects) arrive with + // real line breaks — escapes decode at the string source + // (DatStringResolver → RetailStringEscapes, 2026-08-17 systemic + // round). Wire-sourced text (the appraisal inscription) renders + // verbatim, exactly like retail's ItemExamineUI::AddItemInfo + // @ 0x004AC050 → UIElement_Text::AppendTextWithFont direct append. + foreach (string paragraph in text.Split('\n')) { if (paragraph.Length == 0) { diff --git a/src/AcDream.App/UI/Layout/ItemAppraisalReport.cs b/src/AcDream.App/UI/Layout/ItemAppraisalReport.cs index 5f062813..aa3094bc 100644 --- a/src/AcDream.App/UI/Layout/ItemAppraisalReport.cs +++ b/src/AcDream.App/UI/Layout/ItemAppraisalReport.cs @@ -157,6 +157,14 @@ internal static class ItemAppraisalTextLayout } Vector4 color = ResolveColor(target, fragment.Style); + // WIRE-domain normalize — deliberately NOT the DAT source + // decode (DatStringResolver → RetailStringEscapes, 2026-08-17 + // systemic round): appraisal fragments are server strings + // (long description, use text, inscription), which never pass + // the DAT string source, so this is not a duplicate path. It + // accommodates literal "\n" sequences in ACE's database + // strings; server strings with REAL line breaks flow through + // the Split below either way. string normalized = fragment.Text.Replace( "\\n", "\n", diff --git a/src/AcDream.App/UI/Layout/RetailStringEscapes.cs b/src/AcDream.App/UI/Layout/RetailStringEscapes.cs new file mode 100644 index 00000000..1a239f9d --- /dev/null +++ b/src/AcDream.App/UI/Layout/RetailStringEscapes.cs @@ -0,0 +1,140 @@ +using System.Text; + +namespace AcDream.App.UI.Layout; + +/// +/// Exact port of retail's string-table escape codec +/// (StringTableMetaLanguage::UnescapeString @ 0x0067BDC0 / +/// EscapeString @ 0x0067BBC0 and their character tables +/// GetUnEscapedChar @ 0x0067B750 / GetEscapedChar @ 0x0067B6C0). +/// +/// +/// +/// PLACEMENT (the systemic 2026-08-17 normalization round): retail decodes +/// escapes at the string SOURCE, not per-widget. Every public +/// StringInfo resolution runs the unescape unconditionally before any +/// consumer sees the text — StringInfo::InqString @ 0x0042E490 tail +/// and StringInfo::GetLiteralValue @ 0x0042CA50 both end in +/// UnescapeString. The write side is the inverse: +/// StringInfo::SetLiteralValue @ 0x0042C980 runs EscapeString +/// when storing plain text (and StringInfo::AddVariable_String +/// @ 0x0042E6C0 always stores variables that way), so stored text is +/// escaped, resolved text is decoded, and variable content round-trips +/// verbatim. acdream's equivalent source is ; +/// widgets and controllers receive already-decoded strings and must not +/// re-decode (a second pass corrupts an authored \\n — escaped +/// backslash then 'n' — into a line break). +/// +/// +/// The escape set (byte-verified against the PDB-paired 2013 binary; the +/// metalanguage character-set literal at file offset 0x3FE178 is the ten +/// characters []!{}#\|^$): +/// \n → LF (0x0A), \t → TAB (0x09), \r → CR (0x0D), +/// \q → '"' (0x22), and a backslash before any of the ten +/// metalanguage characters yields that character itself. A backslash before +/// anything else is NOT an escape — retail copies it through verbatim +/// (GetUnEscapedChar returns 0 and UnescapeString's +/// else-branch keeps the current character). +/// +/// +public static class RetailStringEscapes +{ + /// The ten metalanguage-significant characters that escape to + /// themselves. Byte-decoded from the retail binary (see class remarks) — + /// the same literal both character tables test with wcschr. + private const string MetaCharacters = "[]!{}#\\|^$"; + + /// + /// StringTableMetaLanguage::GetUnEscapedChar @ 0x0067B750: the + /// character an escape pair \+ decodes + /// to, or '\0' when the pair is not an escape. + /// + internal static char GetUnEscapedChar(char value) => value switch + { + 'n' => '\n', + 'q' => '"', + 'r' => '\r', + 't' => '\t', + not '\0' when MetaCharacters.Contains(value) => value, + _ => '\0', + }; + + /// + /// StringTableMetaLanguage::GetEscapedChar @ 0x0067B6C0: the + /// character that follows the backslash when + /// must be stored escaped, or '\0' when it is stored verbatim. + /// + internal static char GetEscapedChar(char value) => value switch + { + '\t' => 't', + '\n' => 'n', + '\r' => 'r', + '"' => 'q', + not '\0' when MetaCharacters.Contains(value) => value, + _ => '\0', + }; + + /// + /// StringTableMetaLanguage::UnescapeString @ 0x0067BDC0: decodes + /// every two-character escape pair; all other characters (including a + /// backslash that does not start a recognized pair, and a trailing + /// backslash) copy through verbatim. + /// + public static string Unescape(string value) + { + ArgumentNullException.ThrowIfNull(value); + // Fast path: a string with no backslash cannot contain an escape. + int first = value.IndexOf('\\'); + if (first < 0) + return value; + + var result = new StringBuilder(value.Length); + for (int i = 0; i < value.Length; i++) + { + char current = value[i]; + // Retail reads the character AFTER the candidate backslash (the + // terminator — never an escape — when at the end of the buffer). + char next = i + 1 < value.Length ? value[i + 1] : '\0'; + char unescaped = GetUnEscapedChar(next); + if (current == '\\' && unescaped != '\0') + { + result.Append(unescaped); + i++; // consume the pair + } + else if (current != '\0') + { + result.Append(current); + } + } + return result.ToString(); + } + + /// + /// StringTableMetaLanguage::EscapeString @ 0x0067BBC0: the exact + /// inverse — every character with a mapping + /// is stored as \ + that mapping; everything else verbatim. + /// Unescape(Escape(x)) == x for every — + /// the round-trip retail relies on for template variables. + /// + public static string Escape(string value) + { + ArgumentNullException.ThrowIfNull(value); + StringBuilder? result = null; + for (int i = 0; i < value.Length; i++) + { + char current = value[i]; + char escaped = GetEscapedChar(current); + if (escaped != '\0') + { + result ??= new StringBuilder(value.Length + 4) + .Append(value, 0, i); + result.Append('\\').Append(escaped); + } + else if (current != '\0') + { + result?.Append(current); + } + } + return result?.ToString() ?? value; + } +} diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index b1eb268b..22a915a8 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -3010,10 +3010,10 @@ public sealed class RetailUiRuntime : IDisposable }); if (text is null) return 0u; // no invented English // The authored text stores its blank line as a literal - // "\n\n" two-character escape (live-probed) — same - // convention DatWidgetFactory/IndicatorDetailText already - // unescape for other DAT-authored strings. - text = text.Replace("\\n", "\n", StringComparison.Ordinal); + // "\n\n" two-character escape (live-probed), decoded at + // the string source (DatStringResolver → + // RetailStringEscapes, 2026-08-17 systemic round) — + // `text` arrives with real line breaks. try { return DialogFactory.MakeWait(text, queueKey: 0x10000001u); @@ -4368,13 +4368,13 @@ public sealed class RetailUiRuntime : IDisposable { lock (_bindings.Assets.DatLock) { - return NormalizeRetailNewlines(strings.ResolveTemplate( - stringTableId, - "ID_CharacterManagement_DeleteCharacterConfirmation", - new Dictionary - { - [DatStringResolver.PlayerVariable] = characterName, - })!); + return strings.ResolveTemplate( + stringTableId, + "ID_CharacterManagement_DeleteCharacterConfirmation", + new Dictionary + { + [DatStringResolver.PlayerVariable] = characterName, + })!; } } @@ -4390,16 +4390,16 @@ public sealed class RetailUiRuntime : IDisposable confirmExit)); } + // Escape decoding (the DAT's literal two-character "\n" and friends) + // happens at the string source since the 2026-08-17 systemic round — + // DatStringResolver → RetailStringEscapes, retail's own placement — so + // this is a plain key-hash resolve. The former NormalizeRetailNewlines + // consumer copy is retired (double-decoding corrupts an authored "\\n"). private static string? ResolveCharacterManagementString( DatStringResolver strings, uint tableId, string key) => - strings.Resolve(tableId, DatStringResolver.ComputeHash(key)) is { } value - ? NormalizeRetailNewlines(value) - : null; - - private static string NormalizeRetailNewlines(string value) => - value.Replace("\\n", "\n", StringComparison.Ordinal); + strings.Resolve(tableId, DatStringResolver.ComputeHash(key)); private void ConfigureCharacterCreation() { diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs index 30de665e..859ed34d 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterCreationUiControllerTests.cs @@ -1904,18 +1904,21 @@ public sealed class CharacterCreationUiControllerTests /// /// 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). + /// color than body segments (index 0), and an authored line break + /// splits into stacked lines. The harness resolver models + /// DatStringResolver's post-decode output (the DAT's literal + /// "\n" escape decodes AT THE SOURCE since the 2026-08-17 systemic + /// round), so the fixture feeds a REAL '\n'. 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.Runtime.ResolvedStrings["ID_CharGen_Heritage_StartingSkills"] = "Line one\nLine two"; environment.Controller.Open(); environment.Runtime.SelectHeritageDirect(AluvianId); BumpRevisionAndTick(environment); @@ -1924,7 +1927,7 @@ public sealed class CharacterCreationUiControllerTests 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 + // The source-decoded line break 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); diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs index 6c01c054..5b685043 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterManagementLiveDatTests.cs @@ -138,10 +138,12 @@ public sealed class CharacterManagementLiveDatTests // Finding 1: MakeConfirmExitDialog@0x004ed250's text // (compute_str_hash("ID_CharacterManagement_ConfirmExit"), table // enum 0x10000002 -> 0x23000002). The raw DAT string carries a - // literal two-character "\n" escape (this test's Resolve() helper - // does not normalize it — RetailUiRuntime does, via - // NormalizeRetailNewlines, before handing it to the controller). - Assert.Equal("Are you sure you want to leave?\\n", Resolve(strings, table, + // literal two-character "\n" escape; since the 2026-08-17 systemic + // round DatStringResolver decodes it AT THE SOURCE (retail's own + // placement — StringInfo::InqString @ 0x0042E490's UnescapeString + // tail), so Resolve returns a REAL line break and no consumer + // normalizes again. + Assert.Equal("Are you sure you want to leave?\n", Resolve(strings, table, "ID_CharacterManagement_ConfirmExit")); string confirmation = Assert.IsType(strings.ResolveTemplate( table, diff --git a/tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs index 2ae0193e..bb7dc226 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs @@ -5,8 +5,10 @@ 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). +/// word-wrap + per-segment-color helper feeding GF-2/GF-3/GF-11a and the +/// Summary how-to text (Commit 3). Escape decoding moved to the string +/// source in the 2026-08-17 systemic round (DatStringResolver → +/// RetailStringEscapes) — segments reach Compose with real line breaks. /// public class DatRichTextTests { @@ -16,17 +18,24 @@ public class DatRichTextTests private static UiText MakeTarget(float width) => new() { Width = width, Height = 200f }; + /// Segments arrive source-decoded (real '\n'); Compose keeps + /// the authored break as a line split. A literal backslash-n pair in a + /// segment must stay VERBATIM — re-decoding here is the double-decode + /// hazard the 2026-08-17 round retired. [Fact] - public void Compose_NormalizesLiteralBackslashNEscape() + public void Compose_SplitsOnRealNewlines_AndKeepsLiteralPairsVerbatim() { UiText target = MakeTarget(1000f); // wide enough that nothing wraps - var segments = new[] { new DatRichText.Segment("line one\\nline two", White) }; + var segments = new[] + { + new DatRichText.Segment("line one\nliteral \\n stays", 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); + Assert.Equal("literal \\n stays", lines[1].Text); } [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/DatStringEscapeSweepTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatStringEscapeSweepTests.cs new file mode 100644 index 00000000..915c39e1 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/DatStringEscapeSweepTests.cs @@ -0,0 +1,136 @@ +using System.IO; +using System.Text; +using AcDream.App.UI.Layout; +using AcDream.Content; +using DatReaderWriter.Options; +using StringTable = DatReaderWriter.DBObjs.StringTable; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// 2026-08-17 systemic escape round: installed-DAT sweep of EVERY string +/// table for literal escape content, plus the source-normalization contract +/// ( resolutions must equal +/// of the raw stored text — +/// retail's StringInfo::InqString @ 0x0042E490 placement). This is +/// the measurement companion to the per-consumer normalize retirement: it +/// proves the escape class genuinely exists in shipping data and prints +/// which tables carry it. +/// +public sealed class DatStringEscapeSweepTests +{ + [InstalledDatFact] + public void EveryInstalledStringResolvesSourceDecoded() + { + string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", + "Asheron's Call"); + using var dats = new DatCollection(datDirectory, DatAccessType.Read); + var resolver = new DatStringResolver(dats); + + int tables = 0; + int strings = 0; + int withNewlineEscape = 0; + int withTabEscape = 0; + int withCrEscape = 0; + int withQuoteEscape = 0; + int withMetaSelfEscape = 0; + int withUnknownPair = 0; + int withRealCr = 0; + var perTableNewlines = new SortedDictionary(); + var examples = new List(); + string? userReportedExitText = null; + + foreach (uint tableId in dats.GetAllIdsOfType().Order()) + { + StringTable? table = dats.Get(tableId); + if (table is null) + continue; + tables++; + + foreach ((uint stringId, var entry) in table.Strings) + { + for (int token = 0; token < entry.Strings.Count; token++) + { + string raw = entry.Strings[token].Value; + strings++; + + bool newline = false, unknown = false, meta = false; + for (int i = 0; i < raw.Length - 1; i++) + { + if (raw[i] != '\\') + continue; + char next = raw[i + 1]; + char decoded = RetailStringEscapes.GetUnEscapedChar(next); + switch (decoded) + { + case '\n': newline = true; break; + case '\t': withTabEscape++; break; + case '\r': withCrEscape++; break; + case '"': withQuoteEscape++; break; + case '\0': unknown = true; break; + default: meta = true; break; + } + i++; // the pair is consumed either way it decodes + } + if (newline) + { + withNewlineEscape++; + perTableNewlines[tableId] = + perTableNewlines.GetValueOrDefault(tableId) + 1; + if (examples.Count < 12) + examples.Add( + $"0x{tableId:X8}/0x{stringId:X8}: \"{Truncate(raw)}\""); + } + if (meta) withMetaSelfEscape++; + if (unknown) withUnknownPair++; + if (raw.Contains('\r')) withRealCr++; + + // The source contract: what consumers receive from the + // resolver is EXACTLY the retail unescape of the stored + // text — nothing more (no consumer re-decode is owed), + // nothing less (no escape survives to render literally). + Assert.Equal( + RetailStringEscapes.Unescape(raw), + resolver.Resolve(tableId, stringId, token)); + + if (raw.Contains("exit your character", StringComparison.OrdinalIgnoreCase)) + userReportedExitText = + $"0x{tableId:X8}/0x{stringId:X8}: \"{raw}\""; + } + } + } + + var summary = new StringBuilder() + .AppendLine("[escape-sweep] installed-DAT string-table inventory:") + .AppendLine($" tables={tables} strings={strings}") + .AppendLine($" strings with literal \\n escape: {withNewlineEscape}") + .AppendLine($" \\t pairs: {withTabEscape}; \\r pairs: {withCrEscape}; \\q pairs: {withQuoteEscape}") + .AppendLine($" strings with metalanguage self-escapes: {withMetaSelfEscape}") + .AppendLine($" strings with unrecognized backslash pairs (kept verbatim): {withUnknownPair}") + .AppendLine($" strings containing a REAL CR character: {withRealCr}") + .AppendLine(" \\n-escape counts per table: " + + string.Join(", ", perTableNewlines.Select( + static pair => $"0x{pair.Key:X8}={pair.Value}"))) + .AppendLine(" examples:"); + foreach (string example in examples) + summary.AppendLine($" {example}"); + summary.AppendLine(userReportedExitText is null + ? " user-reported exit-world text: NOT found by content scan" + : $" user-reported exit-world text: {userReportedExitText}"); + Console.WriteLine(summary.ToString()); + + // The escape class must genuinely exist in shipping data — if this + // ever goes to zero the sweep (and the source decode) is measuring + // nothing and needs re-examination, not silent success. + Assert.True( + withNewlineEscape > 0, + "expected at least one installed string carrying the literal \\n escape"); + } + + private static string Truncate(string value) => + (value.Length <= 90 ? value : value[..90] + "…") + .Replace("\r", "").Replace("\n", ""); +} diff --git a/tests/AcDream.App.Tests/UI/Layout/DatStringResolverTemplateTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatStringResolverTemplateTests.cs index 9aefda93..f590f962 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatStringResolverTemplateTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatStringResolverTemplateTests.cs @@ -85,6 +85,72 @@ public sealed class DatStringResolverTemplateTests new Dictionary())); } + /// + /// The 2026-08-17 systemic escape round: resolution decodes the DAT's + /// literal two-character escapes AT THE SOURCE — retail's own placement + /// (StringInfo::InqString @ 0x0042E490's unconditional + /// UnescapeString tail). Consumers receive real line breaks; no + /// per-consumer normalize remains. + /// + [Fact] + public void ResolveDecodesEscapesAtTheSource() + { + var resolver = MakeResolver( + "ID_Confirm_Exit", + fragments: [ + "This will exit your character from the game world.\\n\\nAre you sure?", + ], + variables: []); + + Assert.Equal( + "This will exit your character from the game world.\n\nAre you sure?", + resolver.Resolve( + TableId, DatStringResolver.ComputeHash("ID_Confirm_Exit"))); + } + + [Fact] + public void ResolveAllDecodesEveryVariant() + { + var resolver = MakeResolver( + "ID_Variants", + fragments: ["one\\nline", "two\\tcol"], + variables: []); + + Assert.Equal( + ["one\nline", "two\tcol"], + resolver.ResolveAll( + TableId, DatStringResolver.ComputeHash("ID_Variants"))); + } + + /// + /// Template composition decodes the authored fragments' escapes while + /// variable content round-trips VERBATIM — retail escapes each variable + /// on insert (AddVariable_String @ 0x0042E6C0 → + /// SetLiteralValue(escape=1) @ 0x0042C980) and unescapes the + /// composed whole once, so a player name containing escape-significant + /// characters can never be corrupted by the final decode. + /// + [Fact] + public void ResolveTemplateDecodesFragmentsAndKeepsVariablesVerbatim() + { + var resolver = MakeResolver( + "ID_Delete_Confirmation", + fragments: ["Delete ", "?\\nType 'DELETE' to confirm."], + variables: [DatStringResolver.PlayerVariable]); + + Assert.Equal( + "Delete Odd\\nName?\nType 'DELETE' to confirm.", + resolver.ResolveTemplate( + TableId, + "ID_Delete_Confirmation", + new Dictionary + { + // A pathological name carrying a REAL backslash before + // an 'n' — must come out verbatim, not as a line break. + [DatStringResolver.PlayerVariable] = "Odd\\nName", + })); + } + [Fact] public void UnknownKeyResolvesNull() { diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index e218d444..1ca6c0c2 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -1144,8 +1144,10 @@ public class DatWidgetFactoryTests } /// - /// 2026-08-13 social gate round 3: a MULTILINE authored string (literal - /// backslash-n escapes in the DAT) word-wraps each authored line to the + /// 2026-08-13 social gate round 3: a MULTILINE authored string (a real + /// '\n' — the DAT's literal backslash-n escape decodes at the string + /// source since the 2026-08-17 systemic round, so the resolver seam + /// hands this factory decoded text) word-wraps each authored line to the /// widget's live width — retail's GlyphList draw, the same wrap the /// confirmation dialog view uses. The fellowship empty-state was /// rendering its three authored lines as three clipped runs. @@ -1165,7 +1167,7 @@ public class DatWidgetFactoryTests // Width=100 fits 12 characters per wrapped line. var text = Assert.IsType(DatWidgetFactory.Create( info, NoTex, null, - stringResolve: _ => "one two three four five\\nsix")); + stringResolve: _ => "one two three four five\nsix")); var lines = text.LinesProvider!(); Assert.True(lines.Count >= 3); diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailStringEscapesTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailStringEscapesTests.cs new file mode 100644 index 00000000..c30387ac --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/RetailStringEscapesTests.cs @@ -0,0 +1,103 @@ +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Conformance for — the exact port of +/// retail's string-table escape codec +/// (StringTableMetaLanguage::UnescapeString @ 0x0067BDC0 / +/// EscapeString @ 0x0067BBC0, character tables +/// GetUnEscapedChar @ 0x0067B750 / GetEscapedChar @ 0x0067B6C0). +/// The metalanguage character set is byte-verified against the PDB-paired +/// 2013 binary (file offset 0x3FE178: []!{}#\|^$). +/// +public sealed class RetailStringEscapesTests +{ + [Theory] + [InlineData("line one\\nline two", "line one\nline two")] + [InlineData("a\\tb", "a\tb")] + [InlineData("a\\rb", "a\rb")] + [InlineData("say \\qhi\\q", "say \"hi\"")] + public void Unescape_DecodesTheFourCharacterEscapes( + string raw, string expected) + => Assert.Equal(expected, RetailStringEscapes.Unescape(raw)); + + [Theory] + [InlineData("\\[", "[")] + [InlineData("\\]", "]")] + [InlineData("\\!", "!")] + [InlineData("\\{", "{")] + [InlineData("\\}", "}")] + [InlineData("\\#", "#")] + [InlineData("\\\\", "\\")] + [InlineData("\\|", "|")] + [InlineData("\\^", "^")] + [InlineData("\\$", "$")] + public void Unescape_DecodesEveryMetalanguageSelfEscape( + string raw, string expected) + => Assert.Equal(expected, RetailStringEscapes.Unescape(raw)); + + /// + /// GetUnEscapedChar returns 0 for anything else — retail keeps the + /// backslash verbatim (UnescapeString's else-branch), including a + /// trailing backslash whose "next" character is the terminator. + /// + [Theory] + [InlineData("\\z", "\\z")] + [InlineData("C:\\path\\dir", "C:\\path\\dir")] + [InlineData("ends with \\", "ends with \\")] + [InlineData("\\N upper is not an escape", "\\N upper is not an escape")] + public void Unescape_KeepsUnrecognizedPairsVerbatim( + string raw, string expected) + => Assert.Equal(expected, RetailStringEscapes.Unescape(raw)); + + /// + /// The double-decode hazard the 2026-08-17 systemic round exists to + /// close: an authored escaped backslash before an 'n' decodes ONCE to + /// the literal two characters backslash+n — a second decode pass (the + /// retired per-consumer copies) would corrupt it into a line break. + /// + [Fact] + public void Unescape_EscapedBackslashBeforeN_YieldsLiteralPair() + => Assert.Equal("\\n", RetailStringEscapes.Unescape("\\\\n")); + + [Fact] + public void Unescape_EmptyString_IsEmpty() + => Assert.Equal(string.Empty, RetailStringEscapes.Unescape(string.Empty)); + + /// No backslash → no allocation: the same instance returns. + [Fact] + public void Unescape_NoEscapes_ReturnsTheSameInstance() + { + const string plain = "Please Wait"; + Assert.Same(plain, RetailStringEscapes.Unescape(plain)); + } + + [Theory] + [InlineData("line one\nline two", "line one\\nline two")] + [InlineData("a\tb", "a\\tb")] + [InlineData("a\rb", "a\\rb")] + [InlineData("say \"hi\"", "say \\qhi\\q")] + [InlineData("[x]", "\\[x\\]")] + [InlineData("back\\slash", "back\\\\slash")] + [InlineData("plain", "plain")] + public void Escape_IsTheStorageInverse(string plain, string expected) + => Assert.Equal(expected, RetailStringEscapes.Escape(plain)); + + /// + /// Retail's template-variable round trip + /// (AddVariable_String @ 0x0042E6C0 escapes on insert; + /// InqString @ 0x0042E490 unescapes the composed whole): variable + /// content must come out verbatim. + /// + [Theory] + [InlineData("plain name")] + [InlineData("Odd\\Name")] + [InlineData("multi\nline")] + [InlineData("tabs\tand \"quotes\"")] + [InlineData("[]!{}#\\|^$")] + [InlineData("")] + public void UnescapeOfEscape_RoundTripsVerbatim(string value) + => Assert.Equal(value, RetailStringEscapes.Unescape( + RetailStringEscapes.Escape(value))); +} diff --git a/tests/AcDream.App.Tests/UI/UiButtonTests.cs b/tests/AcDream.App.Tests/UI/UiButtonTests.cs index 5ecb0cd1..48172af5 100644 --- a/tests/AcDream.App.Tests/UI/UiButtonTests.cs +++ b/tests/AcDream.App.Tests/UI/UiButtonTests.cs @@ -553,16 +553,20 @@ public class UiButtonTests Assert.True(confinedWidth < button.Width, "the confined width must be narrower than the full button"); } - // ── R2-2 escape-normalize ──────────────────────────────────────────── + // ── R2-2 authored caption (source-decoded) ─────────────────────────── /// - /// R2-2: BuildButton's own P0x17 caption escape-normalizes the same way - /// BuildText's authored-string path always has — the DAT stores the - /// LITERAL two-character escape "\n" (0x5C 0x6E), and the Profession - /// credits button's own authored caption is exactly this shape. + /// R2-2's successor contract (2026-08-17 systemic round): the DAT's + /// LITERAL two-character escape "\n" (0x5C 0x6E — the Profession + /// credits button's own authored caption is exactly this shape) decodes + /// at the string SOURCE (DatStringResolver → RetailStringEscapes, + /// retail's own placement), so the resolver seam hands BuildButton a + /// caption with a REAL line break — and the factory passes it through + /// verbatim, with no second decode that would corrupt an authored + /// backslash pair. /// [Fact] - public void BuildButton_OwnCaption_NormalizesLiteralBackslashNEscape() + public void BuildButton_OwnCaption_PassesSourceDecodedTextThrough() { uint stringId = 444u; var info = new ElementInfo { Type = 1, Width = 150, Height = 50 }; @@ -575,11 +579,14 @@ public class UiButtonTests var button = Assert.IsType(DatWidgetFactory.Create( info, NoTex, null, - // The raw resolved string carries the LITERAL two characters - // '\' and 'n', matching what the installed DAT actually stores. - stringResolve: value => value.StringId == stringId ? "Attribute\\n Credits" : null)); + // The resolver seam models DatStringResolver's post-decode + // output: a REAL '\n', plus a literal backslash pair that a + // stray second decode would corrupt into a line break. + stringResolve: value => value.StringId == stringId + ? "Attribute\n Credits \\not-an-escape" + : null)); - Assert.Equal("Attribute\n Credits", button.Label); + Assert.Equal("Attribute\n Credits \\not-an-escape", button.Label); } private static UiButton ButtonWithStates(params string[] states)