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);
+ }
+}