From 03e073b748d018f598009a03fd8ce11e09795c0b Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 22:59:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Campaign=20CT=20slice=20CT3=20?= =?UTF-8?q?=E2=80=94=20Titles=20page=20live=20via=20standard=20GUI=20class?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Titles tab (AP-109's known-inert gap) now switches to a real page and CharacterTitlesController binds it entirely through UiTemplateListBox/ UiScrollbar/UiButton — zero bespoke widgets, matching every other social/options row-list page in this codebase. Retail anchors: gmCharacterTitleUI::PostInit @0x0049A610; AddTitleToList @0x0049A840 + FindSortedInsertPosition @0x0049A760 (rows sorted by resolved display text — this port rebuilds the full sorted set on every change rather than a positional splice, since UiTemplateListBox has no insert-at-index primitive and no other consumer needs one either); InfoRegion::SetState(selected?6:1) (row Highlight/DirectState swap, the same mechanism CT1's SEALED VERDICT confirmed for the stat rows); UpdateButtons @0x0049A500 CORRECTED direction (Ghosted unless a row is selected whose id differs from the current display title — no selection IS the Ghosted case); Refresh @0x0049abc0 (display-title text, including the hardcoded "Unknown" fallback, refreshed on both TableReplaced and DisplayTitleChanged per CT2's review anchor 1); Event_SetDisplayCharacterTitle @0x006a5720 (wire-only TitleSet 0x002C send, no local mutation). CharacterStatController.Bind now three-way switches Attributes/Skills/ Titles — Titles is a genuinely separate, non-duplicated page container (CT1 ground truth §3), unlike Attributes/Skills which share one mounted page and only rebind content. The two page captions (0x1000052E/0x10000531) are left untouched: LayoutImporter.BuildText already resolves every element's authored StringInfo caption at import time, so no controller-side string lookup was added. New IGameRuntimeCommands.SetTitle seam on DeferredGameRuntimeStateCommands (InteractionUiRuntimeSources.cs) mirrors the existing Advance() shape. CharacterRuntimeBindings gains Titles/TitleResolver/SendSetTitle; CharacterTitleResolver (CT2) is constructed once at composition time and its .Resolve method group is passed to the controller as a delegate (not the concrete DAT-backed type) so the controller stays hermetically testable without a live IDatReaderWriter. Tests (tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs): binding-seam coverage against the REAL committed character_2100002E.json fixture (verified this session to already carry the Titles page subtree, including the ListBox's own authored TemplateList=[(0x2100005E, 0x10000536)] entry — RowTemplateResolver_ReceivesTheFixturesOwnAuthoredTemplateIds proves the controller reads that authored pair, not a hardcoded one); a hand-authored ElementInfo standing in only for the row template itself (a separate LayoutDesc with no committed fixture yet — CT1 was a live-DAT probe only); sorted-row order, Unknown fallback, row selection/highlight, the ghost truth table (no selection / selected==display / selected!= display), click-sends-exactly-one-SetTitle-and-mutates-nothing, click-while-ghosted-sends-nothing, TableReplaced rebuild (including selection survival when the id is still earned), TitleAdded single-row growth, DisplayTitleChanged text+ghost refresh, and Dispose unsubscription. CharacterStatControllerTests updated for the Titles tab no longer being ClickThrough, plus a new tab-switch visibility test. Register: amends AP-109 (docs/architecture/retail-divergence-register.md) to record the Titles-page half as LIVE; the header identity block and luminance fields remain open for CT4. Suites: full solution 15,405 tests / 0 skips (App 6,130) green. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 2 +- .../InteractionRetainedUiComposition.cs | 11 +- .../InteractionUiRuntimeSources.cs | 9 + .../UI/Layout/CharacterStatController.cs | 56 ++- .../UI/Layout/CharacterTitlesController.cs | 341 +++++++++++++++ src/AcDream.App/UI/RetailUiRuntime.cs | 50 ++- .../UI/Layout/CharacterStatControllerTests.cs | 44 +- .../Layout/CharacterTitlesControllerTests.cs | 414 ++++++++++++++++++ 8 files changed, 913 insertions(+), 14 deletions(-) create mode 100644 src/AcDream.App/UI/Layout/CharacterTitlesController.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 8f828cb9..56cb2c22 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -356,7 +356,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-105 | **PARTIAL 2026-07-13** — inherited scrollbar media/roles now come from DAT (decrement/top `0x06004C69`, increment/bottom `0x06004C6C`), and both chat backends share typed client-command routing plus one retained `ChatVM` for reply state. Retained chat still lacks complete tab/filter/unread, social availability, incoming squelch enforcement, and focus-opacity behavior. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `ChatWindowController.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; chat mount in `GameWindow.cs` | Shared log/send path, wrapping, scrollbar roles, command ownership, and outer maximize geometry work; later chat work consolidates the remaining presentation/filter state | Tabs are no-ops, squelched lines can still render, contextual social actions are absent, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `UIElement_Scrollbar::OnSetAttribute @ 0x004714D0`; `ChatInterface` methods | | ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` | | AP-108 | Paperdoll/AutoWield still omit the remaining missile/held restrictions and corrupt-mask branch of full `AutoWieldIsLegal`, dual-wield/off-hand rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **AutoWear legality retired from this row 2026-07-23:** inventory activation and paperdoll drops now apply the retail clothing-priority/location blocker lookup and exact `"You must remove your %s to wear that"` system notice. **Primary replacement retired 2026-07-14; Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, live doll, AutoWear conflict reporting, and primary weapon/incompatible shield/mismatched ammo blocker sequencing work in peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWieldIsLegal @ 0x0055ED60`; `CPlayerSystem::AutoWearIsLegal @ 0x0055EF40`; `CPlayerSystem::AutoWield @ 0x00560A60`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` | -| AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` | +| AP-109 | **NARROWED 2026-08-24 at Campaign CT slice CT3 — the Titles-page half is now LIVE.** `CharacterTitlesController` binds the Titles page (`gmCharacterTitleUI`, LayoutDesc `0x2100002E` element `0x10000539`) through the standard `UiTemplateListBox`/`UiScrollbar`/`UiButton` classes — no bespoke widgets: the earned-titles list sorted by resolved display string (`FindSortedInsertPosition @0x0049A760`), the current display-title text (`Refresh @0x0049abc0`, including its hardcoded `"Unknown"` fallback, refreshed on both the table-replace and display-change notices), row selection using the row template's own authored Highlight state (the same `InfoRegion::SetState(6)` mechanism CT1 confirmed for the stat rows), the "Set as Display Title" button's Ghosted-unless-a-differing-selection gate (`UpdateButtons @0x0049A500`, CORRECTED direction per the CT campaign plan's CT1 fix round — no selection is the Ghosted case), and the `TitleSet (0x002C)` wire send through CT2's `RuntimeCharacterTitleState`/`IRuntimeCharacterCommands.SetTitle` (no local mutation). What remains open: the header identity block's live displayed-title composition and the level-200 luminance fields (both CT4's job). | `src/AcDream.App/UI/Layout/CharacterTitlesController.cs`; `src/AcDream.App/UI/Layout/CharacterStatController.cs` (three-way Titles tab switch); `src/AcDream.App/UI/RetailUiRuntime.cs` (`MountCharacter`'s title row-template resolver + `CharacterRuntimeBindings`); `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted; the Titles page's binding SEAM (not just its layout/logic) is asserted by CT3's fixture tests | The header identity block (name / heritage+display-title / PK line) and the luminance fields remain unported until CT4 lands | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` | | AP-110 | **NARROWED 2026-08-09 (Slice 5.4, vendor browse panel) — "vendor" retired from the absent-panels list; see AP-161 for the precise successor (Buy/Sell transaction UI, Slice 6).** Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination has its independent authored floaty layout, inscription transaction, retail creature stat/rating/animated-preview presentation, default selection-follow, authored local spell subview with appropriate-formula component state, and the full EoR item-report dispatch: appraisal-only unknowns; exact equipment-set/rating/tinkering/weapon/armor/caster/requirement/XP/healer/rare prose and intentional blank section rows; ordinary/enchantment DAT spell descriptions; live material-decorated appropriate titles plus DAT material and creature names; expiry, decorated material/gem descriptions; and portal/PK restrictions with authored item colors. It still lacks item-object preview, player-dependent effective shield projection, live cooldown-remaining projection, localized augmentation-cost `StringInfo`, exhaustive character detail regions, and exact creature appraisal FontInfo-list selection. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs`; `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs`; `src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, the Slice 5.4 vendor "Items" browse panel (category-filtered stock list, retail's quantity-correct pricing — `ItemHolder::GetObjectSplitSize`'s split-exempt mask, not a flat per-unit price), and the core examination request/presentation/inscription/creature-preview/item-report loop cover the active loops; the residual examination mechanisms require live player/enchantment/localization state or object-preview ownership rather than fabricated content | Item assessments omit only the listed live/localized/preview projections; enchanted/incomplete creature appraisal rows use the normal authored font until the exact FontInfo list is bound; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`; `gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0`; `gmExaminationUI::ExamineSpell @ 0x004B6900`; `SpellExamineUI::ExamineSpell @ 0x004B6210`; `AttributeInfoRegion::Update @ 0x004F1D90`; `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ACCWeenieObject::GetObjectName @ 0x0058E6E0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::AddItemInfo @ 0x004AC050`; `ItemExamineUI::Appraisal_ShowCapacity @ 0x004B2680`; `ItemExamineUI::Appraisal_ShowSpecialProperties @ 0x004B0140`; `ItemExamineUI::Appraisal_ShowWeaponAndArmorData @ 0x004B10E0`; `ItemExamineUI::Appraisal_ShowMagicInfo @ 0x004B2E10`; `ItemExamineUI::Appraisal_ShowDescription @ 0x004B6990`; `MaterialTypeEnumMapper::MaterialTypeToString @ 0x005CD500`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog | | AP-161 | **REVIEW CORRECTIONS 2026-08-09 (Opus review of `92ea3977`, findings F1-F13):** thirteen further fixes, mostly bug-fixes-to-already-claimed-behavior rather than new divergences, so no new AP row is filed for most of them; the exceptions are called out below. F1 ports Buy All's four retail pre-send guards (pyreal/alt-currency affordability, container-slot/item-slot capacity) — see AP-162's narrowing. F2 corrects `AddToBuyList` from upsert to retail's actual ACCUMULATE-with-5000-cap semantics and ports `RemoveFromShop`'s shop-row hide/restore as staging consumes limited vendor supply. F3 corrects `VendorSellAcceptability`'s too-valuable branch to the byte-verified bitwise-complement form (`(~(itemTypeMask >> 16)) & 4`), exempting `PromissoryNote` items. F4 wires `BF_RETAINED` end to end, RETIRING AP-164 below. F5 ports `UpdateDragOver`'s auto-switch-to-Selling-on-hover. F6 corrects sell staging to always record the FULL stack (never the live split slider) and ports `SellSingleItem`'s partial-stack refusal plus its literal amount-1 send. F7 corrects the X-close confirmation string's missing trailing question mark. F8 disposes a live confirmation dialog on session Close/Reset. F9 repaints the Buying/Selling strips' own selection highlight on every selection change, not just a staging change. F10 unstages a sell entry that leaves `ClientObjectTable` and a buy entry whose shop row is retired, the latter with retail's exact notice. F11 reorders `RequestUse`'s eligibility check ahead of `BeginApproach` so an ineligible far target no longer speculatively approaches. F13 makes Sell Item act on the global selection unconditionally, matching retail — a prior version of this port required a staged entry first. New approximations this pass introduced are filed as AP-167 (`SellSingleItem`'s non-empty-container refusal branch not ported) and AP-168 (Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container`). **NARROWED 2026-08-09 (Slice 6b/6c, staging+sell arc) — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES.** `VendorUiController` now fully wires both tabs: Buying (`Add to List`/`Buy Item`/`Buy All`/`Clear Item`/`Clear List`, backed by `VendorStagingList`) and Selling (drag-to-sell via `IItemListDragHandler`, `VendorSellAcceptability`'s port of `VendorProfile::InqAcceptability`, `Sell Item`/`Sell All`/`Clear Item`/`Clear List`), plus the X-close staging confirmation dialog (`RetailDialogFactory`, the exact retail string recovered from the decompiled binary's data segment at `0x007b5bd8`). Sell (`0x0060`) is wired end to end (`VendorRequests.BuildSell`/`WorldSession.SendSell`/`ItemInteractionController.TrySell`). Three narrow residuals from this pass are filed separately rather than folded in here: `InqAcceptability`'s non-sellable bitfield check is unmodeled (AP-164), the Buying tab's stackable-removal-amount test substitutes `VendorShopItem.DescStackSize` for retail's `_maxStackSize` (AP-165), and the Buying/Selling tabs' own per-row/purse count text plus the cross-panel "pending sell" inventory highlight are not wired (AP-166). The two PRE-EXISTING residuals below (dropdown arrow-cap glyph, alt-currency `m_last_sale` simplification) are UNCHANGED by this pass — see the ORIGINAL text below for their citations. **REVIEW CORRECTIONS 2026-08-09 (Opus review of `97cf8738`, findings F1-F9):** none of these are NEW divergences from retail — they are bug fixes that make this row's own claims actually true, so no new AP row is filed for them. F2 fixed the priced/named quantity freezing at a selection-time seed while the Buy button separately read the LIVE slider — both now share one `ResolveBuyQuantity` computation, so the displayed price always equals what a purchase actually charges (retail: `gmVendorUI::RecvNotice_StackSliderChanged` re-runs the SAME display update on every slider change, `pc:203262-203278`). F6 corrected an unauthored "preserve the prior selection if it survives the filter" rule to retail's actual UNCONDITIONAL reselect-to-first-item on every rebuild this controller reaches (`VendorItemsUI::UpdateItemsList`'s notify=1 path, `pc:201180-201184`, confirmed reached by a fresh open AND a same-vendor refresh via `VendorItemsUI::OpenVendor`'s unconditional `SetSelectedItem(...,1)`, `pc:201022`). F7 ported `BuySingleItem`'s stack-size-1 quantity clamp (`pc:201674-201681`) so a stale slider value left over from a previously-selected, DIFFERENT stackable item cannot leak into a non-stack purchase. F8 is recorded inline below, where it corrects this row's own stale claim about the Add-to-List button. **NARROWED 2026-08-09 (Slice 6.1-6.3, buy arc) — TWO of the four consciously-deferred residuals below CLOSE.** Private per-panel selection is GONE: `SelectionState` gains a `Vendor` change source (`SelectionChangeSource.Vendor`) and is now the AUTHORITY — row clicks, the F4 auto-select-first-item fallback, and right-click examine all call `SelectionState.Select`/`Clear`; `VendorUiController` is a CONSUMER (`OnSelectionTransition`) exactly like every sibling panel, matching retail's global `ACCWeenieObject::selectedID`. The examine gap (F7c) is GONE too: `VendorShopItemMaterializer` (`src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs`, Slice 6.1) registers every `ApproachVendor` shop item into `ClientObjectTable` (guid, `ContainerId = vendorGuid`, merge-upserted via the ordinary `Ingest` path, retired on session Close/Reset/vendor-replace via the SAME `VendorState.Changed` subscription) so `AppraisalUiController.Apply`'s lookup now succeeds; `VendorUiController.ExamineItem` wires `UiItemList.ExamineItemRequested` to `ItemInteractionController.ExamineSelectedOrEnterMode`, mirroring `ExternalContainerController`. **Double-click-to-buy was investigated (research doc `docs/research/2026-08-08-slice6-vendor-transactions-research.md` §B.2) and confirmed ABSENT from retail** — no `gmVendorUI::CheckForDoubleClick`/`VendorItemsUI::CheckForDoubleClick` symbol exists anywhere in the 18,366-function named table, unlike sibling panels (`gmContractsUI::CheckForDoubleClick`, `gmPageListUI::CheckForDoubleClick`) that DO have one; acdream intentionally does NOT add a double-click shortcut — a user request for it as a deliberate acdream-only UX addition would need its own AP row, per CLAUDE.md's no-invented-mechanisms discipline. The remaining two residuals (dropdown arrow-cap glyph, alt-currency `m_last_sale` simplification) are UNCHANGED — see below. New approximations this pass introduced are filed separately as AP-162 (no client-side Buy pre-check) and AP-163 (shop-item guid-collision policy). **Original REWRITTEN text follows, retained for the two still-open residuals:** `VendorUiController` mounts LayoutDesc `0x21000012`/root `0x100000B7` and fully wires only the authored "Items" tab (`0x100000B9` — `VendorItemsUI`: category-filtered browse list with retail's quantity-correct pricing, `ItemHolder::GetObjectSplitSize`'s `0xDC41CB0` split-exempt mask ported locally rather than a flat per-unit price). The other two authored tabs render and switch pages (so the layout looks complete) but are otherwise INERT: "Buying" (`0x100000BA`, `VendorBuyUI` — staged-purchase review/confirm, buttons `0x100000C9`/`CA`/`CB`/`CC`) and "Selling" (`0x100000BB`, `VendorSellUI` — staged-sale review/confirm, buttons `0x100000D2`/`D3`/`D4`/`D5`) have no data binding at all. The "Items" page's own `Buy` button (`0x100000C2`) correctly enables/disables with selection (`UiButton.Enabled`, retail `SetState(1)`/`SetState(0xd)`) and Slice 6.3 wires it to a real immediate single-item purchase (`gmVendorUI::BuySingleItem`, `pc:201661` — `VendorRequests.BuildBuy`/`WorldSession.SendBuy`, opcode `0x005F`). **Review correction 2026-08-09 (F8):** `Add to List` (`0x100000C3`, staging) does NOT enable/disable with selection — it is PERMANENTLY disabled (`VendorUiController.SetActionButtonsEnabled`), because it has no wired `OnClick` at all; an enabled-but-dead button is a worse affordance than a disabled one, so it stays disabled until the "Buying" tab's staging list is actually implemented. The Buy opcode exists on the wire now; Sell (`0x0060`) does not. `VendorProfile::InqAcceptability` (sell-eligibility filtering) is unread — moot without a sell UI. Two divergences remain of the four the F1-F8 fix pass originally recorded — the other two (private per-panel selection, unwired shop-item examine) CLOSED at Slice 6.1/6.2, see the NARROWED note above: (1) the closed-dropdown button face reuses the row template's own two sprites (`0x060012B3` normal/`0x060012B4` open) through `UiMenu`'s existing single-texture 3-slice `DrawButtonFace` instead of retail's authored two-piece label+arrow-cap assembly (label `0x1000034D` + a separate 17x19 arrow cap `0x1000034E` with its own `0x060012B1`/`0x060012B2` states) — a cosmetic gap only; the popup panel and its rows render with the exact authored geometry and sprites; (2) the alt-currency "you have" holding reads `VendorShopProfile.AlternateCurrencyAmount` directly instead of tracking retail's `gmVendorUI.m_last_sale` purchase debit — moot until a sell path exists to actually debit it, since `m_last_sale` only changes on a completed SALE (retail's own `m_last_sale == 0` case, `pc:204091`/`OpenVendor`'s `this->m_last_sale = 0` reset at `pc:203790`/`203800`); Slice 6.3's buy path does not touch `m_last_sale` either (retail's own buy flow never writes it), so this residual is unaffected by the buy arc landing. The "Buying"/"Selling" staging tabs (`VendorBuyUI`/`VendorSellUI`) and the full Sell wire remain unwired — unchanged Slice 6b/6c territory per contract decision 6, not a residual of THIS row. | `src/AcDream.App/UI/Layout/VendorUiController.cs`; `src/AcDream.Core/Items/VendorState.cs`; `src/AcDream.Core.Net/GameEventWiring.cs`; `src/AcDream.App/UI/RetailUiRuntime.cs` | Slice 6 (`docs/plans/2026-07-23-world-interaction-completion.md`) owns the authoritative buy/sell transaction command, quantity/stack-split selection, drag-to-sell consumption, and `InqAcceptability`-gated sell UI — Slice 5.4's charter was browse only. Buy (6.3), the global `ACCWeenieObject::selectedID` coupling (6.2), and shop-item `ClientObjectTable` registration (6.1) are now DONE, landing exactly the seam this row's original filing fenced off; drag-to-sell consumption and `InqAcceptability`-gated sell UI remain Slice 6b/6c territory. | A player can browse, select, examine, and BUY (Slice 6.3) — the only remaining unbuilt transaction is Sell. Clicking "Buying"/"Selling" still shows an empty page with no error or explanation, matching "present but does nothing" rather than a disabled/hidden affordance. The dropdown's closed-state button face is missing its separate arrow-cap glyph — a minor visual gap, not a functional one; the open popup itself is pixel-faithful to the authored template. | `gmVendorUI::OpenVendor` pc:203650 (`m_itemsUI`/`m_buyUI`/`m_sellUI` construction, `PostInit` pc:199906, `m_last_sale` reset pc:203790/203800); `VendorBuyUI::VendorBuyUI` pc:199717; `VendorSellUI::VendorSellUI` pc:199753; `VendorProfile::InqAcceptability` pc:484768-484797; `UIElement_Menu::MakePopup` pc:120705-120764, `::Initialize` pc:120789-120828; `VendorItemsUI::UpdateItemsUI` pc:202539-202820; `VendorItemsUI::UpdateItemsList` pc:201029-201190; `ItemHolder::GetObjectSplitSize` pc:401465-401477; `gmToolbarUI::HandleSelectionChanged` pc:198740-198790 (mask `0xDC41CB0` at pc:198784); `ACCWeenieObject::GetObjectName` pc:409056-409132; `docs/research/2026-08-08-slice5-vendor-browse-research.md` §B.4, §D | | AP-162 | **NARROWED 2026-08-09 (Opus review of `92ea3977`, finding F1) — the "Buy All" half of this row CLOSES.** `VendorUiController.BuyAllButtonPressed` now ports all four of retail's client-side pre-send guards (pyreal affordability `pc:204017`, alt-currency affordability `pc:204032`, container-slot capacity `pc:204053`, item-slot capacity `pc:204067`) — see `ComputeBuyTransactionValue`/`ComputeBuySlotsNeeded`/`CountPlayerContents`, each guard returning with staging fully intact and retail's own exact notice string (`"You don't have enough money"` at `0x007b57b4`, `"You must empty some slots in your backpack first"` at `0x007b5750`, both byte-recovered). The container-vs-item slot CLASSIFICATION this port uses (`ItemType.Container` instead of retail's bitfield/capacity test) is its own new, narrower approximation — filed separately as AP-168 rather than folded in here. Only `TryBuy`'s single-item Buy path (Items tab's own Buy button, and the Buying tab's "Buy Item") remains WITHOUT a client-side pre-check — the risk/oracle columns below now describe that one remaining case, not both. **EXTENDED 2026-08-09 (Slice 6b) — the same omission now also covers "Buy All".** `ItemInteractionController.TryBuyAll` (the batched-send path `VendorUiController.BuyAllButtonPressed` calls) sends unconditionally too, without porting retail's `pc:204017/204032/204053/204067` affordability/pack-capacity pre-checks for the MULTI-item case either — the same latency-not-correctness tradeoff this row already documents for the single-item path, extended rather than duplicated into a second row; retiring this row should port both the single- and batched-send pre-checks together. **Filed 2026-08-09, Slice 6.3 (buy wire + button).** Retail's `BuySingleItem` (`pc:201661`) performs TWO client-side pre-checks before ever sending `CM_Vendor::Event_Buy`: (a) an affordability check against `this->m_totalValue` (pyreal) or `shopVendorProfile->trade_num - m_last_sale` (alt-currency), showing a LOCAL string via `ECM_UI::SendNotice_DisplayStringInfo` and returning without sending anything on failure (`pc:201686-201717`); (b) a pack/container-capacity pre-check (`pc:201730-201746`) mirroring the server's own check. acdream's `ItemInteractionController.TryBuy` sends unconditionally once the shared use/inventory gate is free — no client-side affordability or capacity check runs before dispatch. Every refused purchase pays a full round-trip (send → server rejects → `UseDone`/`GameEventInventoryServerSaveFailed`) instead of failing instantly and silently client-side. **Swept 2026-08-09 (F4 review fix):** `TryBuy` now also checks whether `sendBuy` actually reached a live, in-world session before marking the reservation dispatched — an orthogonal reservation-leak bug fix (no session ever produced a stray permanent busy-lock), not an affordability/capacity check; this row's scope and residual are unchanged. | `src/AcDream.App/UI/ItemInteractionController.cs` (`TryBuy`) | The research doc's own open question 1 (`docs/research/2026-08-08-slice6-vendor-transactions-research.md`) recommends deferring this: the server is authoritative either way (ACE re-validates both affordability and capacity server-side — `Vendor.BuyItems_ValidateTransaction`, `Vendor.cs:431-571`), so omitting the client pre-check is a LATENCY/UX gap, not a correctness one — a refused purchase still fails cleanly, just one round-trip later than retail. | A player attempting to buy something they cannot afford or have no room for sees the failure arrive after a network round-trip instead of instantly; against a well-behaved ACE server no purchase can succeed that retail's pre-check would have blocked, so no transaction outcome differs — only its latency. Retiring this row means porting `BuySingleItem`'s two pre-check branches (`pc:201686-201746`) into `TryBuy` before dispatch. | `gmVendorUI::BuySingleItem` pc:201661/0x004C2820 (affordability pc:201686-201717, capacity pc:201730-201746); `Vendor.BuyItems_ValidateTransaction` (`references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:431-571`); `docs/research/2026-08-08-slice6-vendor-transactions-research.md` §D point 4, Open question 1 | diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index d74658b3..6bd8a257 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -554,6 +554,11 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory RuntimeAdvancementKind.TrainSkill, statId, credits)); + // Campaign CT slice CT3 (2026-08-24): the Titles page's DAT + // id -> display-string chain (CT2). Constructed once — its own + // constructor does no DAT I/O (only .Resolve reads touch the + // dats), matching the characterCreationStrings precedent below. + var characterTitleResolver = new CharacterTitleResolver(d.Dats); checkpoint(InteractionRetainedUiCompositionPoint.CharacterSheetCreated); uint MagicSkillLevel(MagicSchool school) @@ -822,7 +827,11 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory && d.Inventory.Objects.Get(guid) is { } vendorCandidate && vendorCandidate.ContainerId == d.Inventory.Vendor.VendorId && VendorSplitPolicy.IsSplitExempt(vendorCandidate.Type)), - Character: new CharacterRuntimeBindings(characterSheet), + Character: new CharacterRuntimeBindings( + characterSheet, + d.Character.Titles, + characterTitleResolver, + SendSetTitle: titleId => late.GameRuntime.SetTitle(titleId)), Inventory: new InventoryRuntimeBindings( d.Inventory.Objects, () => d.PlayerIdentity.ServerGuid, diff --git a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs index 87f8b1cd..afaaf7ae 100644 --- a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs +++ b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs @@ -150,6 +150,15 @@ internal sealed class DeferredGameRuntimeStateCommands generation, new RuntimeAdvancementCommand(kind, statId, cost))); + /// Campaign CT slice CT3 (2026-08-24): retail TitleSet + /// (0x002C) — same generation-capturing late seam as every other + /// method here. + /// is the only caller. + public RuntimeCommandResult SetTitle(uint titleId) => + Invoke((commands, generation) => commands.Character.SetTitle( + generation, + titleId)); + // Campaign LA slice LA8: the retained character-management screen uses // the same generation-capturing late seam as every gameplay panel. The // screen never receives GameRuntime or WorldSession and cannot retain a diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index 0cdfad33..5ad9865b 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -35,10 +35,15 @@ namespace AcDream.App.UI.Layout; /// line-1 label = "Experience To Raise:", line-1 value = raise cost, /// line-2 label = "Unassigned Experience:", line-2 value = UnassignedXp. /// -/// Tab states: Attributes = "Open", Skills = "Closed", Titles = "Closed". -/// Source: UIElement::SetState @ 0x00464E70 and UIStateId.Open (0x0C) / -/// UIStateId.Closed (0x0B). The imported Type-12 tab owns its authored font color and -/// propagates the state to its three chrome children through PassToChildren. +/// Tab states: whichever of Attributes/Skills/Titles is active shows "Open" +/// (0x0C); the other two show "Closed" (0x0B) — Attributes is the retail-authored +/// default. Source: UIElement::SetState @ 0x00464E70. The imported Type-12 +/// tab owns its authored font color and propagates the state to its three chrome +/// children through PassToChildren. Campaign CT slice CT3 (2026-08-24) wires the +/// Titles tab click to a real page switch (previously a known-inert AP-109 gap); +/// owns the Titles page's OWN content +/// (row list, display-title text, Set-as-Display button) once its page container +/// is shown. /// /// Raise buttons: 0x10000246 (×1) + 0x100005EB (×10). State "Normal" = affordable /// (UIStateId.Normal, 0x01), state "Ghosted" = unaffordable or no selection @@ -146,6 +151,7 @@ public static class CharacterStatController { Attributes, Skills, + Titles, } public enum RaiseTargetKind @@ -278,6 +284,11 @@ public static class CharacterStatController UiElement? skillsTab = layout.FindElement(TabSkillsId); UiElement? titlesTab = layout.FindElement(TabTitlesId); UiElement? contentPage = FindDirectChildById(layout.Root, AttributesPageId); + // CT3: unlike Attributes/Skills (which share ONE mounted page and just + // rebind its content — see the Attributes/Skills tab-switch note above), + // the Titles page (0x10000539) is its OWN separate, non-duplicated subtree + // (CT1 ground truth §3) that must be actually shown/hidden. + UiElement? titlesPage = FindDirectChildById(layout.Root, TitlesPageId); // Name (18px from dat FontDid), Heritage (14px), PkStatus (14px): // Fix C: pass null → Label's null-guard keeps the build-time dat font. @@ -472,9 +483,12 @@ public static class CharacterStatController RetailTabBinding.SetClick(attributesTab, () => SwitchTab(CharacterStatTab.Attributes)); RetailTabBinding.SetClick(skillsTab, () => SwitchTab(CharacterStatTab.Skills)); - // Titles remain the known AP-109 gap. Keep its retail-authored closed visual, - // but do not make an inert page look interactive until that controller lands. - RetailTabBinding.SetClick(titlesTab, null); + // CT3 (2026-08-24): the Titles tab now switches to its own real page + // (previously the known AP-109 gap — retail-authored closed visual, but + // no click routing at all). Content population is + // CharacterTitlesController's job, bound separately by the caller + // against the SAME imported layout root. + RetailTabBinding.SetClick(titlesTab, () => SwitchTab(CharacterStatTab.Titles)); UpdateTabStates(); // ── Active-page selection (fixes the dark-overlay) ───────────────────── @@ -501,8 +515,30 @@ public static class CharacterStatController attrSel[0] = -1; skillSel[0] = -1; SetFooterSelected(false); - RebuildActiveList(); - RefreshActiveRaiseButtons(); + + // CT3: Titles is a genuinely separate page container (unlike + // Attributes/Skills, which share one mounted page and only rebind + // its content — see RebuildActiveList) — actually flip visibility + // between it and the shared Attributes/Skills content page. + bool showTitles = tab == CharacterStatTab.Titles; + if (titlesPage is not null) titlesPage.Visible = showTitles; + if (contentPage is not null) contentPage.Visible = !showTitles; + + if (showTitles) + { + // Titles authors its own (unused) copies of the raise buttons + // (CT1 ground truth); nothing on this page ever selects a stat + // row, so keep them hidden rather than rebuilding a list this + // tab does not show. + foreach (var b in allRaise1) b.Visible = false; + foreach (var b in allRaise10) b.Visible = false; + } + else + { + RebuildActiveList(); + RefreshActiveRaiseButtons(); + } + UpdateTabStates(); Console.WriteLine($"[CharacterStat] Tab click: {tab}"); } @@ -511,7 +547,7 @@ public static class CharacterStatController { RetailTabBinding.SetOpen(attributesTab, activeTab[0] == CharacterStatTab.Attributes); RetailTabBinding.SetOpen(skillsTab, activeTab[0] == CharacterStatTab.Skills); - RetailTabBinding.SetOpen(titlesTab, false); + RetailTabBinding.SetOpen(titlesTab, activeTab[0] == CharacterStatTab.Titles); } void RebuildActiveList() diff --git a/src/AcDream.App/UI/Layout/CharacterTitlesController.cs b/src/AcDream.App/UI/Layout/CharacterTitlesController.cs new file mode 100644 index 00000000..2e4a44a5 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterTitlesController.cs @@ -0,0 +1,341 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Runtime; + +namespace AcDream.App.UI.Layout; + +/// +/// Campaign CT slice CT3 (2026-08-24): binds the character window's Titles +/// page (LayoutDesc 0x2100002E, element 0x10000539 — +/// gmCharacterTitleUI) to CT2's +/// owner through the standard / +/// / classes only — no bespoke +/// widgets, matching every other social/options row-list page in this +/// codebase (, +/// , +/// ). +/// +/// +/// +/// Rows. AddTitleToList @0x0049A840 resolves each row's text +/// through CharacterTitleTable::GetCharacterTitleFromID (ported as +/// , CT2) and inserts it SORTED +/// (FindSortedInsertPosition @0x0049A760 — an ordinal string sort on +/// the resolved display text). This port rebuilds the full sorted row set on +/// every change () rather than performing a true +/// positional splice: has no insert-at-index +/// primitive, and no other consumer in this +/// codebase needs one either (Friends/Squelch/Fellowship/Allegiance/chargen +/// skills/the Options tabs all rebuild-on-change the same way) — the +/// resulting VISIBLE order is retail-exact even though the underlying +/// mechanism is "rebuild," not "splice." +/// +/// +/// Selection + highlight. Retail's InfoRegion::SetState +/// mechanism (confirmed for the sibling stat rows by CT1's SEALED VERDICT) +/// applies SetState(selected ? 6 : 1) directly to the row element — +/// state 6 is . The title row +/// template (0x10000536) authors that exact Highlight state +/// (0x06001AAF) alongside its DirectState background +/// (0x06004CCA), so this controller uses the row's own +/// — no synthesized color +/// swap, unlike pages whose row template lacks a state-based highlight. +/// +/// +/// The "Set as Display Title" button (0x10000535). +/// UpdateButtons @0x0049A500 (CORRECTED per the campaign plan's CT1 +/// fix round): Ghosted (state 0xD) UNLESS a row is SELECTED whose title id +/// DIFFERS from the CURRENT display title; no selection is the Ghosted case, +/// not the enabled one. A click sends +/// CM_Social::Event_SetDisplayCharacterTitle (CT2's +/// command seam) — wire only, no +/// local mutation; the ghost gate itself makes an already-current selection +/// unreachable from the UI, so the click handler's own defensive re-check is +/// belt-and-braces, not the primary guard. +/// +/// +/// Display-title text (0x1000052F). Refresh @0x0049abc0 +/// shows the resolved current display title, or retail's hardcoded literal +/// "Unknown" when the id does not resolve — refreshed on BOTH +/// (retail's own +/// RecvNotice_UpdateCharacterTitleTable unconditionally calls +/// Refresh() on every 0x0029, CT2 review anchor 1) and +/// . +/// +/// +/// The two page captions (0x1000052E/0x10000531). Left +/// untouched by this controller — +/// already resolves every element's authored StringInfo caption at +/// import time (ResolveAuthoredString), the SAME mechanism every +/// other DAT-authored label in this window already relies on, so no +/// controller-side string lookup is needed or added here. +/// +/// +public sealed class CharacterTitlesController : IDisposable +{ + public const uint CurrentDisplayTitleTextId = 0x1000052Fu; + public const uint TitleListBoxId = 0x10000532u; + public const uint SetDisplayButtonId = 0x10000535u; + + /// The row template's own text child (0x10000536's + /// single Type-0xC child) — CT1 ground truth §3. + private const uint RowTextId = 0x10000537u; + + /// Retail's hardcoded fallback literal (Refresh + /// @0x0049abc0) for a display title id that does not resolve — + /// ported verbatim, not a StringTable key (CT2 review anchor 3). + private const string UnknownTitleText = "Unknown"; + + private readonly record struct Row(UiElement Root, uint TitleId); + + private readonly RuntimeCharacterTitleState _titles; + private readonly Func _resolveTitle; + private readonly Func _sendSetTitle; + private readonly UiTemplateListBox _listBox; + private readonly UiText? _displayText; + private readonly UiButton? _setDisplayButton; + private readonly List _rows = new(); + private uint? _selectedTitleId; + private bool _disposed; + + private CharacterTitlesController( + RuntimeCharacterTitleState titles, + Func resolveTitle, + Func sendSetTitle, + UiTemplateListBox listBox, + UiText? displayText, + UiButton? setDisplayButton) + { + _titles = titles; + _resolveTitle = resolveTitle; + _sendSetTitle = sendSetTitle; + _listBox = listBox; + _displayText = displayText; + _setDisplayButton = setDisplayButton; + } + + /// + /// Binds the Titles page's list box, scrollbar, display-title text, and + /// Set-as-Display button under (the + /// character window's imported tree — the Titles page's element ids are + /// unique client-wide, so no page-scoped search is needed, unlike the + /// multi-tab social panel's row families). Returns null (logging why) + /// when the list box itself is missing — every other element is + /// optional so a partial import still gets what it can. + /// + /// CT2's + /// method group in production; a delegate (not the concrete DAT-backed + /// class) so this controller stays hermetically testable without a live + /// IDatReaderWriter. + public static CharacterTitlesController? Bind( + UiElement layoutRoot, + RuntimeCharacterTitleState titles, + Func resolveTitle, + Func templateResolver, + Func sendSetTitle) + { + ArgumentNullException.ThrowIfNull(layoutRoot); + ArgumentNullException.ThrowIfNull(titles); + ArgumentNullException.ThrowIfNull(resolveTitle); + ArgumentNullException.ThrowIfNull(templateResolver); + ArgumentNullException.ThrowIfNull(sendSetTitle); + + if (UiElement.FindDescendant(layoutRoot, TitleListBoxId) is not UiTemplateListBox listBox) + { + Console.WriteLine( + $"[D.2b] CharacterTitlesController: ListBox 0x{TitleListBoxId:X8} not " + + "found — the Titles page will not populate."); + return null; + } + listBox.TemplateResolver = templateResolver; + + uint scrollbarElementId = listBox.ScrollbarElementId; + UiElement? scrollbarElement = scrollbarElementId == 0 + ? null + : UiElement.FindDescendant(layoutRoot, scrollbarElementId); + if (scrollbarElement is UiScrollbar scrollbar) + scrollbar.Model = listBox.Scroll; + else + Console.WriteLine( + $"[D.2b] CharacterTitlesController: scrollbar 0x{scrollbarElementId:X8} " + + "not found — the Titles list will not scroll."); + + UiText? displayText = + UiElement.FindDescendant(layoutRoot, CurrentDisplayTitleTextId) as UiText; + UiButton? setDisplayButton = + UiElement.FindDescendant(layoutRoot, SetDisplayButtonId) as UiButton; + + var controller = new CharacterTitlesController( + titles, resolveTitle, sendSetTitle, listBox, displayText, setDisplayButton); + controller.WireButton(); + + titles.TableReplaced += controller.OnTableReplaced; + titles.TitleAdded += controller.OnTitleAdded; + titles.DisplayTitleChanged += controller.OnDisplayTitleChanged; + + controller.RebuildRows(); + controller.RefreshDisplayText(); + controller.RefreshButtonGhost(); + return controller; + } + + private void WireButton() + { + if (_setDisplayButton is null) return; + _setDisplayButton.OnClick = () => + { + // Belt-and-braces re-check (CT2 review anchor 2): retail's real + // guard is the Ghosted state itself — UiButton refuses to raise + // OnClick while !Enabled — so this branch is normally + // unreachable from a real click, but a direct-call test (or a + // stray event) must still send nothing while ghosted, and never + // wait for a confirmation ACE does not send when re-setting the + // already-current title. + if (_selectedTitleId is not uint id || id == _titles.DisplayTitleId) + return; + _sendSetTitle(id); + }; + } + + /// + /// 0x0029 CharacterTitle — retail's own Refresh() is + /// unconditional here (CT2 review anchor 1), and UnPack always + /// rebuilds mTitleList from scratch. + /// itself decides whether the current selection survives (it does when + /// the selected id is still earned in the new table). + /// + private void OnTableReplaced() + { + RebuildRows(); + RefreshDisplayText(); + RefreshButtonGhost(); + } + + /// + /// 0x002B UpdateTitle, add half — CT2's F1 fix already dedupes + /// this event to genuine new memberships only (a repeat add fires no + /// event at all), so every firing here is a real new row. + /// + private void OnTitleAdded(uint titleId) + { + RebuildRows(); + RefreshButtonGhost(); + } + + private void OnDisplayTitleChanged(uint titleId) + { + RefreshDisplayText(); + RefreshButtonGhost(); + } + + /// + /// Full sorted rebuild — see the class remarks for why this port + /// rebuilds rather than performing retail's literal single-row + /// positional insert. Preserves scroll position + /// (). The current + /// selection survives when the selected id is still present in the + /// rebuilt row set; otherwise it is cleared here so the Set-as-Display + /// button's ghost state can never desync from what is actually + /// highlighted (a selection pointing at a no-longer-visible row would + /// leave the button enabled with nothing shown selected). + /// + private void RebuildRows() + { + _listBox.FlushPreservingScroll(); + _rows.Clear(); + + // A3/CT2 doc warning: EarnedTitleIds allocates a fresh array per + // read — safe here (a UI refresh call site, not a per-frame poll). + var sorted = new List<(uint Id, string Text)>(); + foreach (uint id in _titles.EarnedTitleIds) + sorted.Add((id, _resolveTitle(id) ?? UnknownTitleText)); + // FindSortedInsertPosition @0x0049A760: ordinal string sort on the + // resolved display text. + sorted.Sort(static (a, b) => string.CompareOrdinal(a.Text, b.Text)); + + foreach ((uint id, string text) in sorted) + { + UiElement? row = _listBox.AddItemFromTemplateList(0); + if (row is null) continue; + + if (row is UiDatElement datRow) + { + // Generic Type-3 container fallback (DatWidgetFactory) — + // "generic decoration; behavioral widgets opt back in" (its + // own class doc). Same page-opt-in shape + // CharacterCreationSkillsPage uses for its selectable rows. + datRow.ClickThrough = false; + uint capturedId = id; + datRow.OnClick = () => SelectRow(capturedId); + } + + if (UiElement.FindDescendant(row, RowTextId) is UiText rowText) + { + string capturedText = text; + rowText.LinesProvider = () => [new UiText.Line(capturedText, Vector4.One)]; + } + + _rows.Add(new Row(row, id)); + } + + if (_selectedTitleId is uint selected && !_rows.Exists(r => r.TitleId == selected)) + _selectedTitleId = null; + + ApplyRowHighlights(); + } + + private void SelectRow(uint titleId) + { + if (_disposed) return; + _selectedTitleId = titleId; + ApplyRowHighlights(); + RefreshButtonGhost(); + } + + /// Retail InfoRegion::SetState(selected ? 6 : 1) — the + /// row's OWN authored Highlight/DirectState media swap, not a + /// synthesized color (see class remarks). + private void ApplyRowHighlights() + { + foreach (Row row in _rows) + { + if (row.Root is IUiDatStateful stateful) + { + stateful.TrySetRetailState( + row.TitleId == _selectedTitleId + ? UiButtonStateMachine.Highlight + : UiButtonStateMachine.Normal); + } + } + } + + private void RefreshDisplayText() + { + if (_displayText is null) return; + string text = _resolveTitle(_titles.DisplayTitleId) ?? UnknownTitleText; + _displayText.LinesProvider = () => [new UiText.Line(text, Vector4.One)]; + } + + /// + /// UpdateButtons @0x0049A500 (CORRECTED — campaign plan CT1 fix + /// round): Ghosted UNLESS a row is selected whose title id DIFFERS from + /// the current display title. No selection is the Ghosted case. + /// + private void RefreshButtonGhost() + { + if (_setDisplayButton is null) return; + bool shouldGhost = _selectedTitleId is not uint id || id == _titles.DisplayTitleId; + _setDisplayButton.TrySetRetailState( + shouldGhost ? UiButtonStateMachine.Ghosted : UiButtonStateMachine.Normal); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _titles.TableReplaced -= OnTableReplaced; + _titles.TitleAdded -= OnTitleAdded; + _titles.DisplayTitleChanged -= OnDisplayTitleChanged; + } +} diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index e743ce94..88ee83ac 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -156,7 +156,22 @@ public sealed record ToolbarRuntimeBindings( // vendor-owned split-exempt-seed predicate (research doc §C.1). Func IsVendorSplitExempt); -public sealed record CharacterRuntimeBindings(CharacterSheetProvider Provider); +/// The sheet + raise-request flow (pre-existing). +/// Campaign CT slice CT2's RuntimeCharacterTitleState +/// owner — CT3's binds +/// directly against it. +/// CT2's DAT id->string chain +/// (CharacterTitleTable::GetCharacterTitleFromID). Constructed once at +/// composition time — its own constructor does no DAT I/O. +/// Retail TitleSet (0x002C) — wire only, no +/// local mutation (CT2's IRuntimeCharacterCommands.SetTitle via the +/// late-bound game-runtime command seam, same shape as every fellowship/ +/// allegiance command above). +public sealed record CharacterRuntimeBindings( + CharacterSheetProvider Provider, + RuntimeCharacterTitleState Titles, + CharacterTitleResolver TitleResolver, + Func SendSetTitle); /// /// Campaign OP slice OP3 (2026-08-11): bindings the retail Options panel @@ -532,6 +547,7 @@ public sealed class RetailUiRuntime : IDisposable private CharacterManagementUiMountCoordinator? _characterManagementMount; private CharacterCreationUiMountCoordinator? _characterCreationMount; private IDisposable? _characterSheetSubscription; + private Layout.CharacterTitlesController? _characterTitlesController; private ResourceShutdownTransaction? _shutdown; private bool _disposed; @@ -4040,6 +4056,37 @@ public sealed class RetailUiRuntime : IDisposable currentSheet = provider.BuildSheet(); refreshRows(); }); + + // CT3 (2026-08-24): the Titles page's row template lives in a + // SEPARATE LayoutDesc (0x2100005E, element 0x10000536 — CT1 ground + // truth §3), reachable only through the targeted single-root + // ImportInfos overload — same caching resolver shape the social + // panel's Friends/Fellowship/Allegiance row families already use. + var titleRowTemplates = new Layout.RowTemplateResolver( + (layoutId, elementId) => LayoutImporter.ImportInfos( + _bindings.Assets.Dats, layoutId, elementId), + info => + { + var strings = new DatStringResolver(_bindings.Assets.Dats); + return LayoutImporter.Build( + info, + _bindings.Assets.ResolveSprite, + _bindings.Assets.DefaultFont, + _bindings.Assets.ResolveFont, + strings.Resolve).Root; + }); + UiElement? TitleTemplateResolver(uint templateLayoutId, uint templateElementId) + { + lock (_bindings.Assets.DatLock) + return titleRowTemplates.Resolve(templateLayoutId, templateElementId); + } + _characterTitlesController = Layout.CharacterTitlesController.Bind( + layout.Root, + _bindings.Character.Titles, + _bindings.Character.TitleResolver.Resolve, + TitleTemplateResolver, + _bindings.Character.SendSetTitle); + RetailWindowHandle handle = RetailWindowFrame.Mount( Host.Root, layout.Root, @@ -4799,6 +4846,7 @@ public sealed class RetailUiRuntime : IDisposable () => { _characterSheetSubscription?.Dispose(); + _characterTitlesController?.Dispose(); Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged; WindowLockPresentation.Dispose(); WindowOpacity.Dispose(); diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index 19ba6170..4b8b07eb 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -802,7 +802,10 @@ public class CharacterStatControllerTests Assert.Equal( new uint[] { 0x06005D93u, 0x06005D95u, 0x06005D97u }, skills.Children.Cast().Select(child => child.ActiveMedia().File)); - Assert.True(titles.ClickThrough); + // CT3 (2026-08-24): the Titles tab is no longer inert — it switches + // to a real page (previously AP-109's known gap). + Assert.False(titles.ClickThrough); + Assert.NotNull(titles.OnClick); } [Fact] @@ -826,6 +829,45 @@ public class CharacterStatControllerTests Assert.Equal(RetailUiStateIds.Open, Assert.IsAssignableFrom(child).ActiveRetailStateId)); } + /// + /// CT3 (2026-08-24): unlike Attributes/Skills (which share ONE mounted + /// page and only rebind its content), the Titles page (0x10000539) is a + /// genuinely separate, non-duplicated subtree (CT1 ground truth §3) that + /// must actually be shown/hidden on tab switch. + /// + [Fact] + public void TitlesTab_Click_ShowsTitlesPageAndHidesAttributesSkillsContent() + { + var layout = FixtureLoader.LoadCharacter(); + var titlesTab = Assert.IsType(layout.FindElement(CharacterStatController.TabTitlesId)); + var attributesTab = Assert.IsType(layout.FindElement(CharacterStatController.TabAttribId)); + var titlesPage = layout.FindElement(CharacterStatController.TitlesPageId); + var attributesPage = layout.FindElement(CharacterStatController.AttributesPageId); + Assert.NotNull(titlesPage); + Assert.NotNull(attributesPage); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter, + spriteResolve: id => (id, 16, 16)); + + Assert.True(attributesPage!.Visible); + Assert.False(titlesPage!.Visible); + + titlesTab.OnClick!(); + + Assert.True(titlesPage.Visible); + Assert.False(attributesPage.Visible); + Assert.Equal(RetailUiStateIds.Open, titlesTab.ActiveRetailStateId); + Assert.Equal(RetailUiStateIds.Closed, attributesTab.ActiveRetailStateId); + + // Switching back to Attributes restores the shared content page and + // re-hides Titles. + attributesTab.OnClick!(); + + Assert.True(attributesPage.Visible); + Assert.False(titlesPage.Visible); + Assert.Equal(RetailUiStateIds.Closed, titlesTab.ActiveRetailStateId); + } + // ── Affordability helpers (GetRaiseCost) ────────────────────────────────── [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs new file mode 100644 index 00000000..fa3dd684 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs @@ -0,0 +1,414 @@ +using System.Numerics; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Runtime; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Campaign CT slice CT3: hermetic (no DAT, no live runtime) tests for +/// . Binding-seam coverage +/// (feedback_test_the_binding_seam.md) uses the REAL committed +/// character_2100002E.json fixture — verified (2026-08-24) to already +/// carry the whole Titles page subtree, including the ListBox's own +/// authored TemplateList entry pointing at (0x2100005E, +/// 0x10000536) — so every element this controller binds except the row +/// template ITSELF (a separate LayoutDesc CT1 could only reach via a live +/// DAT probe, with no committed fixture yet) comes from the real imported +/// tree, not a hand-built stand-in. +/// +public sealed class CharacterTitlesControllerTests +{ + // Row template ground truth (docs/research/2026-08-24-campaign-ct-dat- + // ground-truth.md §3): LayoutDesc 0x2100005E, element 0x10000536 — a + // 270x24 Type-3 container with DirectState/Highlight media and one + // Type-0xC text child (0x10000537). + private const uint RowTemplateLayoutId = 0x2100005Eu; + private const uint RowTemplateElementId = 0x10000536u; + private const uint RowTextId = 0x10000537u; + private const uint RowNormalSprite = 0x06004CCAu; + private const uint RowHighlightSprite = 0x06001AAFu; + + private static ElementInfo BuildRowTemplateInfo() + { + var info = new ElementInfo + { + Id = RowTemplateElementId, + Type = 3u, + Width = 270f, + Height = 24f, + }; + info.StateMedia[""] = (RowNormalSprite, 3); + info.StateMedia["Highlight"] = (RowHighlightSprite, 1); + info.Children.Add(new ElementInfo + { + Id = RowTextId, + Type = 0xCu, + Width = 270f, + Height = 24f, + HJustify = HJustify.Left, + FontColor = Vector4.One, + }); + return info; + } + + private static UiElement? FakeRowTemplateResolver(uint layoutId, uint elementId) + => LayoutImporter.Build(BuildRowTemplateInfo(), static _ => (0u, 0, 0), null).Root; + + private sealed class Harness + { + public required ImportedLayout Layout; + public required UiTemplateListBox ListBox; + public required UiText DisplayText; + public required UiButton SetDisplayButton; + public required RuntimeCharacterTitleState Titles; + public required Dictionary Names; + public required List SentTitleIds; + public required CharacterTitlesController Controller; + + public IReadOnlyList Rows => + ListBox.ViewportForTest?.Children ?? []; + + public string RowText(UiElement row) => + ((UiText)UiElement.FindDescendant(row, RowTextId)!).LinesProvider().Single().Text; + + public uint RowMedia(UiElement row) => + ((UiDatElement)row).ActiveMedia().File; + } + + // ── Binding seam ───────────────────────────────────────────────────── + + [Fact] + public void Bind_FindsEveryTitlesPageElement_InTheRealImportedFixture() + { + Harness h = BindWithEarnedTitles([], displayTitleId: 0u); + + Assert.NotNull(h.Controller); + Assert.Equal(CharacterTitlesController.TitleListBoxId, h.ListBox.DatElementId); + Assert.Equal(CharacterTitlesController.CurrentDisplayTitleTextId, h.DisplayText.DatElementId); + Assert.Equal(CharacterTitlesController.SetDisplayButtonId, h.SetDisplayButton.DatElementId); + // The authored scrollbar (0x10000533, the ListBox's own + // ScrollbarElementId) must actually be wired to the list's scroll + // model, not merely present. + var scrollbar = Assert.IsType( + h.Layout.FindElement(h.ListBox.ScrollbarElementId)); + Assert.Same(h.ListBox.Scroll, scrollbar.Model); + } + + [Fact] + public void Bind_MissingListBox_ReturnsNullWithoutThrowing() + { + var root = new UiPanel(); + var titles = new RuntimeCharacterTitleState(); + + CharacterTitlesController? controller = CharacterTitlesController.Bind( + root, + titles, + static _ => null, + FakeRowTemplateResolver, + static _ => new RuntimeCommandResult(RuntimeCommandStatus.Inactive, default)); + + Assert.Null(controller); + } + + [Fact] + public void RowTemplateResolver_ReceivesTheFixturesOwnAuthoredTemplateIds() + { + // The REAL fixture's ListBox (0x10000532) authors TemplateList = + // [(0x2100005E, 0x10000536)] (dat property 0x64) — this proves the + // controller's AddItemFromTemplateList(0) call actually reads that + // authored entry rather than a hardcoded pair of its own. + var seen = new List<(uint LayoutId, uint ElementId)>(); + UiElement? Recording(uint layoutId, uint elementId) + { + seen.Add((layoutId, elementId)); + return FakeRowTemplateResolver(layoutId, elementId); + } + + BindWithEarnedTitles( + [1u], displayTitleId: 0u, + names: new() { [1u] = "Adventurer" }, + rowResolver: Recording); + + (uint layoutId, uint elementId) = Assert.Single(seen); + Assert.Equal(RowTemplateLayoutId, layoutId); + Assert.Equal(RowTemplateElementId, elementId); + } + + private static Harness BindWithEarnedTitles( + IReadOnlyList earnedIds, + uint displayTitleId, + Dictionary? names = null, + Func? rowResolver = null) + { + ImportedLayout layout = FixtureLoader.LoadCharacter(); + var titles = new RuntimeCharacterTitleState(); + titles.ReplaceTable(displayTitleId, earnedIds.ToArray()); + Dictionary resolvedNames = names ?? new Dictionary(); + var sent = new List(); + + RuntimeCommandResult SendSetTitle(uint id) + { + sent.Add(id); + return new RuntimeCommandResult(RuntimeCommandStatus.Accepted, default); + } + + string? ResolveTitle(uint id) => + resolvedNames.TryGetValue(id, out string? name) ? name : null; + + CharacterTitlesController? controller = CharacterTitlesController.Bind( + layout.Root, + titles, + ResolveTitle, + rowResolver ?? FakeRowTemplateResolver, + SendSetTitle); + Assert.NotNull(controller); + + var listBox = Assert.IsType( + layout.FindElement(CharacterTitlesController.TitleListBoxId)); + var displayText = Assert.IsType( + layout.FindElement(CharacterTitlesController.CurrentDisplayTitleTextId)); + var button = Assert.IsType( + layout.FindElement(CharacterTitlesController.SetDisplayButtonId)); + + return new Harness + { + Layout = layout, + ListBox = listBox, + DisplayText = displayText, + SetDisplayButton = button, + Titles = titles, + Names = resolvedNames, + SentTitleIds = sent, + Controller = controller!, + }; + } + + // ── Row sort + content ────────────────────────────────────────────── + + [Fact] + public void Rows_AreSortedAlphabeticallyByResolvedTitleText() + { + Harness h = BindWithEarnedTitles( + [13u, 5u, 1u], + displayTitleId: 0u, + names: new() + { + [1u] = "Adventurer", + [5u] = "Life Mage", + [13u] = "War Mage", + }); + + Assert.Equal(3, h.Rows.Count); + Assert.Equal( + ["Adventurer", "Life Mage", "War Mage"], + h.Rows.Select(h.RowText)); + } + + [Fact] + public void Rows_UnresolvedTitle_ShowsRetailUnknownLiteral() + { + Harness h = BindWithEarnedTitles( + [99u], + displayTitleId: 0u, + names: []); + + UiElement row = Assert.Single(h.Rows); + Assert.Equal("Unknown", h.RowText(row)); + } + + // ── Selection + highlight ───────────────────────────────────────────── + + [Fact] + public void SelectingARow_AppliesHighlightMedia_AndDeselectsTheOthers() + { + Harness h = BindWithEarnedTitles( + [1u, 5u], + displayTitleId: 0u, + names: new() { [1u] = "Adventurer", [5u] = "Life Mage" }); + UiElement first = h.Rows[0]; + UiElement second = h.Rows[1]; + + ((UiDatElement)first).OnClick!(); + + Assert.Equal(RowHighlightSprite, h.RowMedia(first)); + Assert.Equal(RowNormalSprite, h.RowMedia(second)); + + ((UiDatElement)second).OnClick!(); + + Assert.Equal(RowNormalSprite, h.RowMedia(first)); + Assert.Equal(RowHighlightSprite, h.RowMedia(second)); + } + + // ── Ghost truth table (UpdateButtons @0x0049A500, CORRECTED direction) ─ + + [Fact] + public void Ghost_NoSelection_ButtonIsGhosted() + { + Harness h = BindWithEarnedTitles([1u], displayTitleId: 0u, names: new() { [1u] = "Adventurer" }); + + Assert.False(h.SetDisplayButton.Enabled); + } + + [Fact] + public void Ghost_SelectedRowEqualsCurrentDisplayTitle_ButtonIsGhosted() + { + Harness h = BindWithEarnedTitles( + [1u, 5u], displayTitleId: 5u, + names: new() { [1u] = "Adventurer", [5u] = "Life Mage" }); + // Sorted order: Adventurer(1), Life Mage(5) — select the row whose + // id equals the current display title (5). + UiElement lifeMageRow = h.Rows.Single(r => h.RowText(r) == "Life Mage"); + + ((UiDatElement)lifeMageRow).OnClick!(); + + Assert.False(h.SetDisplayButton.Enabled); + } + + [Fact] + public void Ghost_SelectedRowDiffersFromCurrentDisplayTitle_ButtonIsNormal() + { + Harness h = BindWithEarnedTitles( + [1u, 5u], displayTitleId: 5u, + names: new() { [1u] = "Adventurer", [5u] = "Life Mage" }); + UiElement adventurerRow = h.Rows.Single(r => h.RowText(r) == "Adventurer"); + + ((UiDatElement)adventurerRow).OnClick!(); + + Assert.True(h.SetDisplayButton.Enabled); + } + + // ── Click -> SetTitle wire send ──────────────────────────────────────── + + [Fact] + public void ClickingSetDisplay_SendsExactlyOneSetTitleWithTheSelectedId_AndMutatesNothingLocally() + { + Harness h = BindWithEarnedTitles( + [1u, 13u], displayTitleId: 1u, + names: new() { [1u] = "Adventurer", [13u] = "War Mage" }); + UiElement warMageRow = h.Rows.Single(r => h.RowText(r) == "War Mage"); + ((UiDatElement)warMageRow).OnClick!(); + + h.SetDisplayButton.OnClick!(); + + Assert.Equal([13u], h.SentTitleIds); + // No optimistic local mutation — CT2's own contract (ACE sends no + // echo when re-setting the current title; the display title only + // ever changes from a DisplayTitleChanged event). + Assert.Equal(1u, h.Titles.DisplayTitleId); + Assert.Equal("Adventurer", h.DisplayText.LinesProvider().Single().Text); + } + + [Fact] + public void ClickingSetDisplay_WhileGhosted_SendsNothing() + { + Harness h = BindWithEarnedTitles([1u], displayTitleId: 0u, names: new() { [1u] = "Adventurer" }); + + // No row selected -> Ghosted. A direct OnClick invocation bypasses + // UiButton's own Enabled-gated event routing, so this exercises the + // controller's own belt-and-braces re-check. + h.SetDisplayButton.OnClick!(); + + Assert.Empty(h.SentTitleIds); + } + + // ── Wire events ───────────────────────────────────────────────────── + + [Fact] + public void TableReplaced_RebuildsRows_AndClearsSelection() + { + Harness h = BindWithEarnedTitles( + [1u], displayTitleId: 0u, names: new() { [1u] = "Adventurer", [13u] = "War Mage" }); + ((UiDatElement)h.Rows[0]).OnClick!(); + Assert.True(h.SetDisplayButton.Enabled); // selected, differs from display(0) + + h.Titles.ReplaceTable(0u, [13u]); + + Assert.Equal(["War Mage"], h.Rows.Select(h.RowText)); + // The previously-selected row no longer exists post-rebuild -> back + // to the no-selection Ghosted state. + Assert.False(h.SetDisplayButton.Enabled); + } + + [Fact] + public void TableReplaced_SelectedTitleStillEarned_KeepsSelectionHighlighted() + { + Harness h = BindWithEarnedTitles( + [1u, 5u], displayTitleId: 0u, + names: new() { [1u] = "Adventurer", [5u] = "Life Mage" }); + UiElement lifeMageRow = h.Rows.Single(r => h.RowText(r) == "Life Mage"); + ((UiDatElement)lifeMageRow).OnClick!(); + Assert.True(h.SetDisplayButton.Enabled); + + // A resend of the SAME table (retail's own Refresh() is + // unconditional — CT2 review anchor 1) must not silently desync the + // ghost state from the still-valid selection. + h.Titles.ReplaceTable(0u, [1u, 5u]); + + UiElement rebuiltLifeMageRow = h.Rows.Single(r => h.RowText(r) == "Life Mage"); + Assert.Equal(RowHighlightSprite, h.RowMedia(rebuiltLifeMageRow)); + Assert.True(h.SetDisplayButton.Enabled); + } + + [Fact] + public void TitleAdded_InsertsExactlyOneRow_PreservingExistingRowsInSortedOrder() + { + Harness h = BindWithEarnedTitles( + [1u], displayTitleId: 0u, + names: new() { [1u] = "Adventurer", [5u] = "Life Mage" }); + Assert.Single(h.Rows); + + h.Titles.ApplyUpdateTitle(5u, setAsDisplay: false); + + Assert.Equal(["Adventurer", "Life Mage"], h.Rows.Select(h.RowText)); + } + + [Fact] + public void DisplayTitleChanged_UpdatesTextAndReevaluatesGhost() + { + Harness h = BindWithEarnedTitles( + [1u, 5u], displayTitleId: 0u, + names: new() { [1u] = "Adventurer", [5u] = "Life Mage" }); + UiElement lifeMageRow = h.Rows.Single(r => h.RowText(r) == "Life Mage"); + ((UiDatElement)lifeMageRow).OnClick!(); + Assert.True(h.SetDisplayButton.Enabled); // selected(5) != display(0) + + // Simulates the server echo (0x002B UpdateTitle, setAsDisplay=true) + // that a real Set-as-Display send would eventually produce. + h.Titles.ApplyUpdateTitle(5u, setAsDisplay: true); + + Assert.Equal("Life Mage", h.DisplayText.LinesProvider().Single().Text); + // Selection now equals the (new) current display title -> Ghosted. + Assert.False(h.SetDisplayButton.Enabled); + } + + [Fact] + public void DisplayTitleText_UnresolvedId_ShowsRetailUnknownLiteral() + { + Harness h = BindWithEarnedTitles([1u], displayTitleId: 77u, names: new() { [1u] = "Adventurer" }); + + Assert.Equal("Unknown", h.DisplayText.LinesProvider().Single().Text); + } + + [Fact] + public void DisplayTitleText_NoDisplayTitleSet_ShowsRetailUnknownLiteral() + { + Harness h = BindWithEarnedTitles([1u], displayTitleId: 0u, names: new() { [1u] = "Adventurer" }); + + Assert.Equal("Unknown", h.DisplayText.LinesProvider().Single().Text); + } + + // ── Lifecycle ─────────────────────────────────────────────────────── + + [Fact] + public void Dispose_UnsubscribesFromTitleEvents() + { + Harness h = BindWithEarnedTitles([1u], displayTitleId: 0u, names: new() { [1u] = "Adventurer" }); + + h.Controller.Dispose(); + + // Must not throw, and must not rebuild the (now-orphaned) rows. + h.Titles.ReplaceTable(0u, [1u, 5u]); + Assert.Single(h.Rows); + } +}