From f8a2258979155c55b072e5bd6d7a4aed2d489fa7 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 08:36:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Campaign=20AS=20AS2=20=E2=80=94=20e?= =?UTF-8?q?xamination=20header=20identity=20block=20(G1/G2/G3/G5/G10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retail's CharExamineUI::SetAppraiseInfo (@0x004B45F0, player-target examine subview) binds four fixed header lines that acdream was feeding from the wrong appraisal properties: - 0x10000150 (Heritage) got raw string prop 4 verbatim. Retail composes " " via InqGenderHeritageDisplay (@0x005B5AE0) from Int 113 Gender + Int 188 HeritageGroup, falling back to the assessed object's creature-type name only when HeritageGroup == 0. Reuses the Campaign CT gender/heritage tables in CharacterIdentityText (new GenderHeritageDisplay helper) and the controller's existing CreatureDisplayNameResolver for the creature-type fallback arm. - 0x10000151 (Profession/title) got AllegianceName (string 47) — the title line was never shown at all. Now resolves the CURRENT display title from Int 261 CharacterTitleId through CharacterTitleResolver (CharacterTitleTable::GetCharacterTitleFromID @0x005C6ED0), falling back to String 5 Template verbatim when the id is absent or unresolvable. RetailUiRuntime.MountAppraisal now wires the SAME CharacterTitleResolver instance the D.2b Character panel already owns (_bindings.Character.TitleResolver), resolved per call under DatLock — never captured once at mount time, per the secure-trade deferred-Func lesson. - 0x10000152 (PlayerKiller) got MonarchsName (string 11) — never shown. Ruling R7: retail reads the LOCAL weenie's PWD bits (ACCWeenieObject::IsPK/IsPKLite @0x0058C8B0/@0x0058C8A0), never the appraisal payload. Now reads the assessed ClientObject's PublicWeenieBitfield directly (bit 0x20 -> "Player Killer", bit 0x02000000 -> "Player Killer Lite", else "Non-Player Killer"). Apply()'s existing bail-out when the object has left the local table already matches retail's "weenie is gone, leave the line cleared". - 0x1000053A (AllegianceName) invented a literal "Assessment incomplete" on failed assess — zero retail provenance, deleted outright. Retail clears the element first (ClearCreatureText already does this every ApplyCreature call) then sets String 47 AllegianceName only inside the Int 30 AllegianceRank >= 1 gate. Scoped to the character branch since retail's CreatureExamineUI (monsters) never binds this element at all. None of the four lines are success-gated — ACE sends the int/string tables even on a failed assess, matching retail's own composition. First-ever AppraisalView.Character controller test coverage (gap G10): header composition, title/allegiance fallback and gating, all three PK variants plus the missing-object clear case, failed-assess rendering with a repo grep confirming the invented literal is gone, and a regression pin proving the monster (character: false) path is untouched. Register: files AD-114 for the examination preview's animated clone (mirrors the assessed target's live current pose via CreatureAppraisalFramePresenter) versus retail's independently animated private CreatureMode clone (BasicCreatureExamineUI::Init @0x004AB9C0) — owner-ruled intentional deviation, 2026-08-25 ("we animate it, and I like it"). Full hermetic suite green: 15,469 tests passed, 0 failed, 0 skipped. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 3 +- .../UI/Layout/AppraisalUiController.cs | 116 +++++- .../UI/Layout/CharacterIdentityText.cs | 26 ++ src/AcDream.App/UI/RetailUiRuntime.cs | 17 +- .../UI/Layout/AppraisalUiControllerTests.cs | 386 +++++++++++++++++- 5 files changed, 533 insertions(+), 15 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index a471ea5d..7f4876cc 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -67,7 +67,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 87 active rows (AD-113 filed 2026-08-25 at Campaign CT slice CT-GF1 — `UiMenu`'s inline-drawn popup opts out of the new client-wide ancestor-clip default (`ExpandsClipForPopup`), standing in for retail's separate top-level popup region; AD-112 filed 2026-08-23 with the sky default-script port — camera-anchored synthetic script owners instead of retail's sky-cell physics objects; 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- +## 2. Adaptation (AD) — 88 active rows (AD-114 filed 2026-08-25 at Campaign AS slice AS2, owner-ruled ("we animate it, and I like it") — the examination window's preview clone tracks the assessed creature's live current animated pose every frame, where retail's clone plays its own private `CreatureMode` cycle decoupled from the live target's actual motion; AD-113 filed 2026-08-25 at Campaign CT slice CT-GF1 — `UiMenu`'s inline-drawn popup opts out of the new client-wide ancestor-clip default (`ExpandsClipForPopup`), standing in for retail's separate top-level popup region; AD-112 filed 2026-08-23 with the sky default-script port — camera-anchored synthetic script owners instead of retail's sky-cell physics objects; 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 @@ -111,6 +111,7 @@ readiness/requeue adaptation. See | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| AD-114 | **Filed 2026-08-25 at Campaign AS slice AS2, owner-ruled 2026-08-25 (verbatim "we animate it, and I like it").** acdream's examination-window preview (`CreatureAppraisalFramePresenter` / `RetailCreatureAppraisalCloneFactory`) shares the assessed target's already-resolved live MeshRefs and re-synchronizes them every frame, so the preview clone plays the SAME current animated pose the live target is actually doing right now (attack, cast, run, idle, ...). Retail's `BasicCreatureExamineUI::Init @0x004AB9C0` instead clones the selected physics object ONCE, fixes its heading at 191.367905°, and lets its own private `CreatureMode` animate that clone independently — decoupled from whatever the live target is currently doing. | `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs` (`CreatureAppraisalFramePresenter`, `RetailCreatureAppraisalCloneFactory`) | Explicit owner direction, 2026-08-25 (`docs/plans/2026-08-25-assess-window-parity-campaign.md`: "The animated 3D paperdoll is an INTENTIONAL acdream deviation... Keep it"), noted alongside the owner's own observation that retail's static-clone colors are buggy — porting the decoupled-motion clone would not even be a faithfulness win here. | None expected — a deliberate, user-approved visual improvement over retail's decoupled clone motion, not a game-feel divergence; a future faithfulness audit assuming the preview mirrors retail's independent `CreatureMode` cycle would be surprised to see it track the live target's pose instead. | `BasicCreatureExamineUI::Init @0x004AB9C0`; `docs/plans/2026-08-25-assess-window-parity-campaign.md` | | AD-112 | **Filed 2026-08-23 with the sky default-script port (issues #28/#2, phase C.1.5c).** Retail plays the sky carriers' aurora/lightning/thunder PES by creating real `CPhysicsObj`s in the viewer-centered sky cells (`GameSky::MakeObject @0x00506EE0` → `CPhysicsObj::makeObject @0x00513970`; the Setup's `DefaultScript` marks `state \|= 0x80000` and `CPhysicsObj::animate_static_object @0x00513DF0` ticks `ScriptManager::UpdateScripts` + the ParticleManager). acdream plays the same scripts through `PhysicsScriptRunner` synthetic owners anchored at the camera (`SkyPesFrameController`), pass-routed by `props & 1` into the existing SkyPreScene/SkyPostScene particle draws — no sky-cell physics objects exist. Slot persistence keys on (index, gfx id, properties), the `CreateDeletePhysicsObjects @0x005073C0` identity law. The heading/rotation → pose axis mapping uses `UnitY` where retail's `GameSky::CalcFrame @0x00506F80` runs `set_heading` + `grotate`; every current PES carrier authors 0°/0° so the axis is unexercised. | `src/AcDream.App/Rendering/SkyPesFrameController.cs`; `src/AcDream.Core/World/SkyDescLoader.cs` (`SkyObjectData.DefaultScriptId`) | Retail's sky cells are camera-centered, so a camera-anchored world-space owner is the same geometry; the script/emitter engines are the production ones shared with entity effects; `SkyPesFrameControllerTests` pins the identity/persistence/window lifecycle. | A sky carrier authored with nonzero heading/rotation angles would orbit around the wrong axis; a PES whose hooks depend on real part frames (part_index targeting) would find only the synthetic owner's root pose. | `GameSky::MakeObject @0x00506EE0`; `GameSky::CreateDeletePhysicsObjects @0x005073C0`; `CPhysicsObj::animate_static_object @0x00513DF0`; `docs/research/2026-08-23-sky-default-script-port.md`; `SkyPesFrameControllerTests` | | 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 " diff --git a/src/AcDream.App/UI/Layout/AppraisalUiController.cs b/src/AcDream.App/UI/Layout/AppraisalUiController.cs index b5b9a109..d30c59e5 100644 --- a/src/AcDream.App/UI/Layout/AppraisalUiController.cs +++ b/src/AcDream.App/UI/Layout/AppraisalUiController.cs @@ -43,6 +43,12 @@ public sealed class AppraisalUiController : IRetainedPanelController public const uint SpellFormulaListId = 0x1000032Du; private const uint TemplateStringProperty = 5u; + /// + /// PropertyInt 0x105 (261 decimal) — CharacterTitleId. Doubles as the + /// Character-subview dispatch marker () and, as + /// of AS2, the source id for 's + /// current-display-title line (element 0x10000151). + /// private const uint CharacterMarkerIntProperty = 0x105u; private const uint DisplayedNameStringProperty = 0x34u; private const double CreatureRefreshSeconds = 0.75; @@ -77,6 +83,15 @@ public sealed class AppraisalUiController : IRetainedPanelController private readonly Func _resolveComponentIcon; private readonly Func> _spellComponents; private readonly Func _magicSkill; + /// + /// AS2: element 0x10000151's current-display-title chain + /// (CharacterTitleResolver.Resolve under the composition layer's + /// DatLock — resolved PER CALL, never captured once at mount time, per + /// the secure-trade deferred-Func lesson). Defaults to "never resolves" + /// so a caller that supplies nothing simply falls back to String 5 + /// Template every time, matching retail's own fallback branch. + /// + private readonly Func _resolveCharacterTitle; private readonly SpellExamineComponentTemplateFactory? _spellComponentTemplates; private readonly UiText _spellSchool; private readonly UiText _spellMana; @@ -132,7 +147,8 @@ public sealed class AppraisalUiController : IRetainedPanelController Func? resolveComponentIcon, Func>? spellComponents, Func? magicSkill, - SpellExamineComponentTemplateFactory? spellComponentTemplates) + SpellExamineComponentTemplateFactory? spellComponentTemplates, + Func? resolveCharacterTitle) { _layout = layout; _objects = objects; @@ -159,6 +175,7 @@ public sealed class AppraisalUiController : IRetainedPanelController _resolveComponentIcon = resolveComponentIcon ?? (_ => 0u); _spellComponents = spellComponents ?? (_ => []); _magicSkill = magicSkill ?? (_ => 0u); + _resolveCharacterTitle = resolveCharacterTitle ?? (_ => null); _spellComponentTemplates = spellComponentTemplates; _spellSchool = (UiText)layout.FindElement(SpellSchoolTextId)!; _spellMana = (UiText)layout.FindElement(SpellManaTextId)!; @@ -287,7 +304,8 @@ public sealed class AppraisalUiController : IRetainedPanelController Func? resolveComponentIcon = null, Func>? spellComponents = null, Func? magicSkill = null, - SpellExamineComponentTemplateFactory? spellComponentTemplates = null) + SpellExamineComponentTemplateFactory? spellComponentTemplates = null, + Func? resolveCharacterTitle = null) { ArgumentNullException.ThrowIfNull(layout); ArgumentNullException.ThrowIfNull(objects); @@ -339,7 +357,8 @@ public sealed class AppraisalUiController : IRetainedPanelController resolveComponentIcon, spellComponents, magicSkill, - spellComponentTemplates); + spellComponentTemplates, + resolveCharacterTitle); } /// @@ -687,9 +706,10 @@ public sealed class AppraisalUiController : IRetainedPanelController if (character) { - SetText(0x10000150u, GetString(p, 4u)); - SetText(0x10000151u, GetString(p, 47u)); - SetText(0x10000152u, GetString(p, 11u)); + SetText(0x10000150u, BuildCharacterHeritageDisplay(p)); + SetText(0x10000151u, BuildCharacterTitleDisplay(p)); + SetText(0x10000152u, BuildPlayerKillerDisplay(obj)); + SetText(0x1000053Au, BuildAllegianceDisplay(p)); } else { @@ -700,13 +720,91 @@ public sealed class AppraisalUiController : IRetainedPanelController RebuildCreatureStats(appraisal); - SetText(0x1000053Au, appraisal.Success - ? string.Empty - : "Assessment incomplete"); if (newlySelected) ResetCreatureScroll(); } + /// + /// Composes examination element 0x10000150 (Heritage) for a + /// player target. Ground truth: docs/research/2026-08-25-campaign-as- + /// ground-truth.md §2a. Gap G1. + /// + private string BuildCharacterHeritageDisplay(PropertyBundle p) + { + int gender = GetInt(p, CharacterIdentityText.GenderPropertyId); + int heritageGroup = GetInt(p, CharacterIdentityText.HeritageGroupPropertyId); + string creatureFallback = heritageGroup == 0 + ? _creatureNames.Resolve(GetInt(p, 2u)) + : string.Empty; + return CharacterIdentityText.GenderHeritageDisplay( + gender, heritageGroup, creatureFallback); + } + + /// + /// Composes examination element 0x10000151 (Profession/title) for + /// a player target: the current display title from Int 261 + /// CharacterTitleId (, resolved + /// through CharacterTitleResolver's CharacterTitleTable:: + /// GetCharacterTitleFromID @0x005C6ED0 chain), falling back to + /// String 5 Template verbatim when the id is absent or unresolvable. + /// Ground truth §2a, gap G2. + /// (0x105 == 261 decimal) is the SAME property + /// already keys the Character-subview dispatch on. + /// + private string BuildCharacterTitleDisplay(PropertyBundle p) + { + if (p.Ints.TryGetValue(CharacterMarkerIntProperty, out int titleId) + && titleId != 0 + && _resolveCharacterTitle(unchecked((uint)titleId)) is { Length: > 0 } resolved) + { + return resolved; + } + return GetString(p, TemplateStringProperty); + } + + /// PWD bit 5 — ACCWeenieObject::IsPK @0x0058C8B0. + private const uint PlayerKillerPwdBit = 0x20u; + + /// PWD bit 0x19 (25) — ACCWeenieObject::IsPKLite @0x0058C8A0. + private const uint PlayerKillerLitePwdBit = 0x02000000u; + + /// + /// Composes examination element 0x10000152 (PlayerKiller) for a + /// player target. Ruling R7: retail reads the LOCAL weenie's PWD bits — + /// never the appraisal payload's PropertyInt 134 (AP-109's CT4 lesson). + /// is the SAME + /// already resolves names from — the assessed + /// object located before ApplyCreature ever + /// runs. When that lookup fails, already bails out + /// before reaching this method, matching retail's "weenie is gone, leave + /// the line cleared" behavior. Ground truth §2a, gap G3. + /// + private static string BuildPlayerKillerDisplay(ClientObject obj) + { + uint bitfield = obj.PublicWeenieBitfield ?? 0u; + if ((bitfield & PlayerKillerPwdBit) != 0u) + return "Player Killer"; + if ((bitfield & PlayerKillerLitePwdBit) != 0u) + return "Player Killer Lite"; + return "Non-Player Killer"; + } + + /// + /// Composes examination element 0x1000053A (AllegianceName). + /// Retail clears the element first ( + /// already does this for every ApplyCreature call), then sets it + /// to String 47 AllegianceName ONLY inside the Int 30 AllegianceRank + /// >= 1 gate — no gate, no allegiance line. Deletes the former + /// invented "Assessment incomplete" literal (zero retail provenance; + /// was the only occurrence in the repo). Ground truth §2a, gap G5. + /// Retail's CreatureExamineUI (monsters) never binds this + /// element at all — only CharExamineUI (players) does — so this + /// composer is scoped to the character branch, matching that + /// per-class split. + /// + private static string BuildAllegianceDisplay(PropertyBundle p) + => GetInt(p, 30u) >= 1 ? GetString(p, 47u) : string.Empty; + private void RebuildCreatureStats(AppraiseInfoParser.Parsed appraisal) { if (_creatureStats is null || _creatureRowTemplates is null) diff --git a/src/AcDream.App/UI/Layout/CharacterIdentityText.cs b/src/AcDream.App/UI/Layout/CharacterIdentityText.cs index 2aab47c0..d1339159 100644 --- a/src/AcDream.App/UI/Layout/CharacterIdentityText.cs +++ b/src/AcDream.App/UI/Layout/CharacterIdentityText.cs @@ -116,6 +116,32 @@ internal static class CharacterIdentityText _ => null, }; + /// + /// Campaign AS slice AS2 (2026-08-25): examination window element + /// 0x10000150 (Heritage), CharExamineUI::SetAppraiseInfo + /// @0x004B45F0 via AppraisalSystem::InqGenderHeritageDisplay + /// @0x005B5AE0. Unlike (the + /// stat-management panel's header, which never appends a title and has + /// no creature-type fallback), this composes ONLY + /// "<Gender> <Heritage>" — falling back to + /// (the assessed object's + /// creature-type display name, Int 2, resolved through the SAME + /// EnumMapper family already + /// serves the monster view's own 0x1000014E line) ONLY when + /// is exactly 0, matching retail's own + /// integer comparison rather than "the lookup failed". + /// + public static string GenderHeritageDisplay( + int gender, + int heritageGroup, + string? creatureTypeFallback) + { + string? heritage = heritageGroup == 0 + ? creatureTypeFallback + : HeritageGroupDisplayName(heritageGroup); + return Join(GenderDisplayName(gender), heritage); + } + private static string Join(params string?[] parts) { return string.Join(" ", parts diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 6ef49d48..0e9cdc48 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -2113,6 +2113,20 @@ public sealed class RetailUiRuntime : IDisposable return; } + // AS2 (Campaign AS, gap G2): the examination header's title line + // (element 0x10000151) reuses the SAME CharacterTitleResolver + // instance the D.2b Character panel already owns + // (_bindings.Character.TitleResolver, constructed once per + // composition in InteractionRetainedUiComposition — see + // MountCharacter's identical TitleResolver local function). Resolved + // PER CALL under DatLock, never captured once, per the secure-trade + // deferred-Func lesson. + string? ResolveCharacterTitle(uint titleId) + { + lock (_bindings.Assets.DatLock) + return _bindings.Character.TitleResolver.Resolve(titleId); + } + AppraisalUiController? controller = AppraisalUiController.Bind( layout, _bindings.Inventory.Objects, @@ -2132,7 +2146,8 @@ public sealed class RetailUiRuntime : IDisposable resolveComponentIcon: _bindings.Magic.ResolveComponentIcon, spellComponents: _bindings.Magic.SpellComponents, magicSkill: _bindings.Magic.MagicSkill, - spellComponentTemplates: spellComponentTemplates); + spellComponentTemplates: spellComponentTemplates, + resolveCharacterTitle: ResolveCharacterTitle); if (controller is null) { Console.WriteLine( diff --git a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs index e4a55791..aaeb3d56 100644 --- a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs @@ -413,6 +413,352 @@ public sealed class AppraisalUiControllerTests Assert.Equal(new[] { ObjectId, otherObjectId }, sent); } + // ── Campaign AS slice AS2: examination header identity block ────────── + // Ground truth: docs/research/2026-08-25-campaign-as-ground-truth.md §2a, + // gap ledger G1/G2/G3/G5, rulings R6/R7. First-ever AppraisalView.Character + // controller coverage (gap G10). + + [Fact] + public void CharacterResponse_ComposesRetailHeaderIdentityBlock() + { + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Dww", + Type = ItemType.Creature, + PublicWeenieBitfield = 0x20u, // PWD bit 5 -> IsPK + }); + using var interaction = NewInteraction(objects, []); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + new CombatState(), + [], + [], + () => { }, + () => { }, + resolveCharacterTitle: titleId => titleId == 13u ? "War Mage" : null)!; + + interaction.ExamineSelectedOrEnterMode(ObjectId); + var properties = new PropertyBundle(); + properties.Ints[0x71u] = 2; // Gender: Female + properties.Ints[0xBCu] = 1; // HeritageGroup: Aluvian + properties.Ints[0x105u] = 13; // CharacterTitleId (also the Character-view marker) + properties.Ints[30u] = 5; // AllegianceRank >= 1 + properties.Strings[47u] = "The Empire"; // AllegianceName + + Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile()))); + Assert.Equal(AppraisalView.Character, controller.ActiveView); + + Assert.Equal("Female Aluvian", HeaderText(layout, 0x10000150u)); + Assert.Equal("War Mage", HeaderText(layout, 0x10000151u)); + Assert.Equal("Player Killer", HeaderText(layout, 0x10000152u)); + Assert.Equal("The Empire", HeaderText(layout, 0x1000053Au)); + } + + [Fact] + public void CharacterResponse_HeritageFallsBackToCreatureTypeWhenGroupIsZero() + { + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Something Odd", + Type = ItemType.Creature, + }); + using var interaction = NewInteraction(objects, []); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + new CombatState(), + [], + [], + () => { }, + () => { }, + creatureNames: new CreatureDisplayNameResolver( + new Dictionary { [42u] = "Olthoi Guardian" }))!; + + interaction.ExamineSelectedOrEnterMode(ObjectId); + var properties = new PropertyBundle(); + properties.Ints[2u] = 42; // CreatureType fallback source + properties.Ints[0xBCu] = 0; // HeritageGroup == 0 -> use creature-type fallback + properties.Strings[5u] = "Template"; // Character-view marker + + Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile()))); + Assert.Equal(AppraisalView.Character, controller.ActiveView); + Assert.Equal("Olthoi Guardian", HeaderText(layout, 0x10000150u)); + } + + [Fact] + public void CharacterResponse_TitleFallsBackToTemplateStringWhenTitleIdAbsent() + { + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Dww", + Type = ItemType.Creature, + }); + using var interaction = NewInteraction(objects, []); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + new CombatState(), + [], + [], + () => { }, + () => { })!; + + interaction.ExamineSelectedOrEnterMode(ObjectId); + var properties = new PropertyBundle(); + properties.Strings[5u] = "Test Template"; // both the fallback text AND the view marker + + Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile()))); + Assert.Equal(AppraisalView.Character, controller.ActiveView); + Assert.Equal("Test Template", HeaderText(layout, 0x10000151u)); + } + + [Fact] + public void CharacterResponse_TitleClearsWhenIdUnresolvableAndTemplateAbsent() + { + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Dww", + Type = ItemType.Creature, + }); + using var interaction = NewInteraction(objects, []); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + new CombatState(), + [], + [], + () => { }, + () => { }, + // Never resolves any title id. + resolveCharacterTitle: _ => null)!; + + interaction.ExamineSelectedOrEnterMode(ObjectId); + var properties = new PropertyBundle(); + // Int 261 present (satisfies the Character-view marker) but does not + // resolve, and String 5 Template is absent -> element clears. + properties.Ints[0x105u] = 999; + + Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile()))); + Assert.Equal(AppraisalView.Character, controller.ActiveView); + Assert.Equal(string.Empty, HeaderText(layout, 0x10000151u)); + } + + [Theory] + [InlineData(0x20u, "Player Killer")] + [InlineData(0x02000000u, "Player Killer Lite")] + [InlineData(0u, "Non-Player Killer")] + public void CharacterResponse_PlayerKillerLineReflectsLocalObjectPwdBits( + uint publicWeenieBitfield, + string expected) + { + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Dww", + Type = ItemType.Creature, + PublicWeenieBitfield = publicWeenieBitfield, + }); + using var interaction = NewInteraction(objects, []); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + new CombatState(), + [], + [], + () => { }, + () => { })!; + + interaction.ExamineSelectedOrEnterMode(ObjectId); + var properties = new PropertyBundle(); + properties.Strings[5u] = "Template"; + + Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile()))); + Assert.Equal(expected, HeaderText(layout, 0x10000152u)); + } + + [Fact] + public void MissingAssessedObject_PlayerKillerElementStaysClearedAndApplyReturnsFalse() + { + ImportedLayout layout = FixtureLoader.LoadExamination(); + // ObjectId is deliberately never added to the table — retail's + // "the weenie is gone" case (ruling R7). + var objects = new ClientObjectTable(); + using var interaction = NewInteraction(objects, []); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + new CombatState(), + [], + [], + () => { }, + () => { })!; + + interaction.ExamineSelectedOrEnterMode(ObjectId); + var properties = new PropertyBundle(); + properties.Strings[5u] = "Template"; + + UiText pk = Assert.IsType(layout.FindElement(0x10000152u)); + Func> providerBeforeApply = pk.LinesProvider; + + Assert.False(controller.Apply(Parsed(properties, MinimalCreatureProfile()))); + + // Apply bailed out before ApplyCreature/SetText ever ran for this + // element — it never had the chance to be anything but cleared. + Assert.Same(providerBeforeApply, pk.LinesProvider); + } + + [Fact] + public void AllegianceElement_ClearsUnlessRankIsAtLeastOne() + { + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Dww", + Type = ItemType.Creature, + }); + using var interaction = NewInteraction(objects, []); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + new CombatState(), + [], + [], + () => { }, + () => { })!; + interaction.ExamineSelectedOrEnterMode(ObjectId); + + var noRank = new PropertyBundle(); + noRank.Strings[5u] = "Template"; + noRank.Strings[47u] = "The Empire"; + Assert.True(controller.Apply(Parsed(noRank, MinimalCreatureProfile()))); + Assert.Equal(string.Empty, HeaderText(layout, 0x1000053Au)); + + var zeroRank = new PropertyBundle(); + zeroRank.Strings[5u] = "Template"; + zeroRank.Strings[47u] = "The Empire"; + zeroRank.Ints[30u] = 0; + Assert.True(controller.Apply(Parsed(zeroRank, MinimalCreatureProfile()))); + Assert.Equal(string.Empty, HeaderText(layout, 0x1000053Au)); + + var gatedRank = new PropertyBundle(); + gatedRank.Strings[5u] = "Template"; + gatedRank.Strings[47u] = "The Empire"; + gatedRank.Ints[30u] = 1; + Assert.True(controller.Apply(Parsed(gatedRank, MinimalCreatureProfile()))); + Assert.Equal("The Empire", HeaderText(layout, 0x1000053Au)); + } + + [Fact] + public void FailedAssess_StillRendersHeaderLinesFromPresentTableEntries() + { + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Dww", + Type = ItemType.Creature, + PublicWeenieBitfield = 0x02000000u, // PKLite + }); + using var interaction = NewInteraction(objects, []); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + new CombatState(), + [], + [], + () => { }, + () => { }, + resolveCharacterTitle: titleId => titleId == 13u ? "War Mage" : null)!; + interaction.ExamineSelectedOrEnterMode(ObjectId); + + var properties = new PropertyBundle(); + properties.Ints[0x71u] = 1; // Gender: Male + properties.Ints[0xBCu] = 1; // HeritageGroup: Aluvian + properties.Ints[0x105u] = 13; // CharacterTitleId + properties.Ints[30u] = 5; // AllegianceRank + properties.Strings[47u] = "The Empire"; + + Assert.True(controller.Apply( + Parsed(properties, MinimalCreatureProfile(), success: false))); + + Assert.Equal("Male Aluvian", HeaderText(layout, 0x10000150u)); + Assert.Equal("War Mage", HeaderText(layout, 0x10000151u)); + Assert.Equal("Player Killer Lite", HeaderText(layout, 0x10000152u)); + Assert.Equal("The Empire", HeaderText(layout, 0x1000053Au)); + } + + [Fact] + public void CreatureResponse_HeaderIdentityElementsUnaffectedByPlayerFix() + { + // Regression pin (task constraint): the character:false (monster) + // path must keep its pre-AS2 behavior for the four header elements + // AS2 remapped for players. Pre-fix, 0x10000150/51/52 were never + // touched for monsters (stay at their cleared default) and + // 0x1000053A was set to string.Empty whenever Success was true — + // identical to today's post-fix "gated, nothing gates it open" + // result for a bundle with no allegiance data. + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Specter", + Type = ItemType.Creature, + }); + using var interaction = NewInteraction(objects, []); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + new CombatState(), + [], + [], + () => { }, + () => { }, + creatureNames: new CreatureDisplayNameResolver( + new Dictionary { [77u] = "Ghost" }))!; + interaction.ExamineSelectedOrEnterMode(ObjectId); + + var properties = new PropertyBundle(); + properties.Ints[2u] = 77; // CreatureType — no String 5 / Int 261 marker present. + properties.Ints[25u] = 80; + + Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile()))); + Assert.Equal(AppraisalView.Creature, controller.ActiveView); + + Assert.Equal("Ghost", HeaderText(layout, AppraisalUiController.CreatureDisplayNameId)); + Assert.Equal(string.Empty, HeaderText(layout, 0x10000150u)); + Assert.Equal(string.Empty, HeaderText(layout, 0x10000151u)); + Assert.Equal(string.Empty, HeaderText(layout, 0x10000152u)); + Assert.Equal(string.Empty, HeaderText(layout, 0x1000053Au)); + } + [Fact] public void ResponseForNeitherPendingNorCurrent_IsIgnored() { @@ -832,7 +1178,8 @@ public sealed class AppraisalUiControllerTests Func? resolveComponentIcon = null, Func>? spellComponents = null, Func? magicSkill = null, - SpellExamineComponentTemplateFactory? spellComponentTemplates = null) + SpellExamineComponentTemplateFactory? spellComponentTemplates = null, + Func? resolveCharacterTitle = null) => AppraisalUiController.Bind( layout, objects, @@ -852,7 +1199,8 @@ public sealed class AppraisalUiControllerTests resolveComponentIcon, spellComponents, magicSkill, - spellComponentTemplates); + spellComponentTemplates, + resolveCharacterTitle); private static ItemInteractionController NewInteraction( ClientObjectTable objects, @@ -871,13 +1219,14 @@ public sealed class AppraisalUiControllerTests private static AppraiseInfoParser.Parsed Parsed( PropertyBundle properties, AppraiseInfoParser.CreatureProfile? creature = null, - uint guid = ObjectId) + uint guid = ObjectId, + bool success = true) => new( Guid: guid, Flags: creature is null ? AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable : AppraiseInfoParser.IdentifyResponseFlags.CreatureProfile, - Success: true, + Success: success, Properties: properties, SpellBook: [], ArmorProfile: null, @@ -889,6 +1238,35 @@ public sealed class AppraisalUiControllerTests WeaponEnchantments: null, ResistEnchantments: null); + /// Minimal CreatureProfile — just enough for + /// 's CreatureProfile-not-null + /// gate; every attribute is intentionally absent since these tests pin + /// the header identity block, not the attribute rows. + private static AppraiseInfoParser.CreatureProfile MinimalCreatureProfile() + => new( + Flags: 0, + Health: 1u, + HealthMax: 1u, + Strength: null, + Endurance: null, + Quickness: null, + Coordination: null, + Focus: null, + Self: null, + Stamina: null, + Mana: null, + StaminaMax: null, + ManaMax: null, + AttributeHighlights: null, + AttributeColors: null); + + private static string HeaderText(ImportedLayout layout, uint elementId) + { + UiText text = Assert.IsType(layout.FindElement(elementId)); + return string.Join( + '\n', text.LinesProvider().Select(line => line.Text)); + } + private static void AssertSpellText( ImportedLayout layout, uint elementId,