From ed652ed8addd24fd7b216549f7ebea5d8594f75c Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 00:02:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Campaign=20CT=20slice=20CT4=20?= =?UTF-8?q?=E2=80=94=20header=20identity=20block=20retail-exact?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retires the rest of AP-109's UI half: the character panel's Name/Heritage/ PkStatus/Level header identity block is now live and DAT-faithful on both Attributes and Skills pages (verified: CharacterStatController.Bind already scopes Label/LabelAuthoredColor to the ONE physically-visible page container, so both tabs share the same bound widgets). - Name/Heritage/PkStatus/Level switch from hand-picked Body/Gold runtime colors to the widget's own authored DefaultColor (LabelAuthoredColor) — CT1's live-DAT pin (HeaderElements_AuthorExpectedFontsAndColors) confirmed all four already carry the correct FontColor (white/white/white/pale-gold with Outline); the former "runtime color, dat carries none" comment was false. - PkStatus resolves through StringTable 0x23000001 by key (ID_StatManagement_Header_PKStatus_PK/_PKL/_NPK) with a bitwise IsPK/IsPKLite test (gmStatManagementUI::UpdatePKStatus @0x004F00A0) instead of the prior exact-equality switch, which silently dropped combined-flag PlayerKillerStatus values. Live-DAT-verified strings: "Player Killer" / "Player Killer Lite" / "Non-Player Killer" (new InstalledDat pin PkStatusKeys_ResolveExpectedAuthoredStrings). - Level shows "%d"-formatted InqInt(0x19) or the PE-recovered literal "???" when absent (CharacterSheet.Level is now int?). - Heritage line appends CT2/CT3's resolved RuntimeCharacterTitleState display title through CharacterTitleResolver, refreshing live on both TableReplaced (0x0029) and DisplayTitleChanged (0x002B) — CharacterSheetProvider's ChangeBinding now subscribes to both. - Name-line ruling: ships the PLAIN-NAME case only. Retail's allegiance rank-title prefix (AllegianceData::GetFullName @0x005B6950 -> AllegianceSystem::GetTitle @0x005B8DD0) needs a ~200-string, 22-function heritage x gender table (verbatim decomp literals, e.g. GetAluvianMaleTitle @0x005B7BC0's Yeoman/Baronet/.../High King) judged out of reasonable size for this slice. RuntimeAllegianceState already carries the local player's own rank; only the string table is missing. Registered, not silently omitted. - Luminance pair (0x100005C5/0x100005C6): CharacterSheet.AvailableLuminance/ MaximumLuminance (PropertyInt64 6/7) already flow generically through both the PlayerDescription snapshot and the live 0x02CF private-update parsers (no wiring gap). The retail show/hide gate (Level >= 200 && MaximumLuminance != 0, UpdateExperience @0x004F0A70) is wired and toggles Visible on both elements every sheet refresh; the exact caption/value text could not be recovered this slice (retail's SetText source resolves through a Binary-Ninja-mislabeled data pointer, not a StringTable key — a DAT string-table sweep found no match), so content stays unbound rather than guessed. - AP-109 narrowed accordingly (register row amended in the same commit). Tests: CharacterStatControllerTests (heritage composition + live title update, name stays plain, level int/"???" with authored — not constant — color across 3 cases, PK line shows resolved text in authored color across 3 statuses, luminance visibility across 5 level/luminance combinations) and CharacterSheetProviderTests (PK key-by-status resolution including a combined-flag case, no-resolver leaves PkStatus null, Level null-vs-present, title resolution + live refresh on both title events + unsubscribe-on- dispose, luminance Int64 read-through). Full hermetic solution suite green under Release (0 failures across all 14 test projects); InstalledDat pins green. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 2 +- ...6-08-24-character-panel-parity-campaign.md | 50 +++++- .../InteractionRetainedUiComposition.cs | 30 +++- .../UI/Layout/CharacterIdentityText.cs | 31 +++- src/AcDream.App/UI/Layout/CharacterSheet.cs | 25 ++- .../UI/Layout/CharacterSheetProvider.cs | 101 ++++++++++- .../UI/Layout/CharacterStatController.cs | 106 +++++++++-- .../UI/Layout/CharacterPanelLiveDatTests.cs | 29 ++++ .../UI/Layout/CharacterSheetProviderTests.cs | 164 ++++++++++++++++++ .../UI/Layout/CharacterStatControllerTests.cs | 140 +++++++++++++++ 10 files changed, 646 insertions(+), 32 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 56cb2c22..28f3d4a4 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 | **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-109 | **NARROWED FURTHER 2026-08-24 at Campaign CT slice CT4 — the header identity block is now LIVE.** (CT3's Titles-page narrowing above still stands verbatim.) `CharacterStatController`'s Name/Heritage/PkStatus/Level labels now use `LabelAuthoredColor` (the widget's own DAT-set `DefaultColor`/Outline, matching CT1's live-DAT pin — the former hardcoded `Body`/`Gold` runtime constants are deleted); the heritage line appends CT2/CT3's resolved display title (`CharacterSheetProvider` now takes `RuntimeCharacterTitleState`/`CharacterTitleResolver.Resolve`, refreshing on both `TableReplaced` and `DisplayTitleChanged`); the PK line resolves through StringTable `0x23000001` by key (`ID_StatManagement_Header_PKStatus_PK`/`_PKL`/`_NPK`, DAT-verified strings "Player Killer"/"Player Killer Lite"/"Non-Player Killer") with a bitwise IsPK/IsPKLite test (retail `UpdatePKStatus @0x004F00A0`) instead of the prior exact-equality switch; the level shows `"%d"`-formatted `InqInt(0x19)` or the literal `"???"` when absent (both PE-recovered). Two items remain open, both registered rather than silently dropped: (1) the NAME line ships the PLAIN-NAME case only — retail's allegiance-rank prefix (`AllegianceData::GetFullName @0x005b6950` → `AllegianceSystem::GetTitle @0x005b8dd0`) needs a ~200-string, 22-function heritage×gender title table (verbatim in the decomp, e.g. `GetAluvianMaleTitle @0x005b7bc0`'s "Yeoman"/"Baronet"/.../"High King") that CT4 judged out of "reasonable size" for this slice — `RuntimeAllegianceState` already carries the local player's own rank (`_rank`/`ApplyUpdate`, seeded by `0x0020 AllegianceUpdate`), so only the string table is missing; (2) the luminance pair (`0x100005C5`/`0x100005C6`) has its DATA (`CharacterSheet.AvailableLuminance`/`MaximumLuminance`, retail PropertyInt64 6/7 — already flowing generically through both the PlayerDescription snapshot and the live `0x02CF` private-update parsers, no wiring gap) and its retail show/hide GATE (`Level >= 200 && MaximumLuminance != 0`, `UpdateExperience @0x004F0A70`) wired and toggling `Visible`, but no TEXT is bound — the label's caption and the value's composed "available/maximum" string both resolve in retail through a `SetText` call whose source string Binary Ninja mislabels as a vftable slot rather than a StringTable key, and a DAT string-table sweep this slice found no matching entry, so content stays blank pending a PE-byte-decode pass. | `src/AcDream.App/UI/Layout/CharacterStatController.cs` (`LabelAuthoredColor`, `RefreshLuminanceVisibility`); `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs`; `src/AcDream.App/UI/Layout/CharacterSheet.cs`; `src/AcDream.App/UI/Layout/CharacterIdentityText.cs`; `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs`; `src/AcDream.App/UI/Layout/CharacterTitlesController.cs` (Titles page, CT3, unchanged) | Attributes/skills core output and the Titles-page binding seam are user-accepted; CT4's header-identity binding-seam tests assert the composed heritage line, the three PK keys, the level integer/"???" fallback with authored (not constant) color, and the luminance show/hide rule against a real fixture, plus an InstalledDat pin for the three PK strings | A ranked-allegiance character's Name line shows plain name only (no title prefix) until the 22-function table is ported; a level-200+ character with luminance sees the pair correctly appear/disappear but with no caption or numbers until the exact retail string is recovered | `gmStatManagementUI::UpdateCharacterInfo @ 0x004F0770`; `UpdatePKStatus @ 0x004F00A0`; `UpdateExperience @ 0x004F0A70`; `AllegianceData::GetFullName @ 0x005B6950`; `AllegianceSystem::GetTitle @ 0x005B8DD0` | | 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/docs/plans/2026-08-24-character-panel-parity-campaign.md b/docs/plans/2026-08-24-character-panel-parity-campaign.md index 6b4490b6..0aa20007 100644 --- a/docs/plans/2026-08-24-character-panel-parity-campaign.md +++ b/docs/plans/2026-08-24-character-panel-parity-campaign.md @@ -265,12 +265,60 @@ row-template resolver already takes (`RetailUiRuntime.MountCharacter`), and set the list box's authored 24px row height so wheel/line scroll lands row-aligned. -**CT4 — Header identity block.** Retail composition: name; " +**CT4 — Header identity block. CODE-COMPLETE 2026-08-24.** Retail composition: name; " "; PK status line — authored fonts/colors (pure white per probe), live refresh on display-title change and PK status, identical on Attributes AND Skills pages. Level color from the authored element. Retires the rest of AP-109's UI half. +**CT4 landing notes (2026-08-24).** Verified the existing `Label(...)` seam +already covers both Attributes/Skills page copies — `CharacterStatController` +binds the SAME physically-visible container (contentPage = the Attributes +page chain) for both tabs; the Skills-page duplicate header subtree is never +shown (a test now pins this: `Bind_HeaderElements_UseVisibleAttributesPageWhenIdsAreDuplicated`). +All four header identity elements (Name/Heritage/PkStatus/Level) switched +from hand-picked `Body`/`Gold` runtime colors to the widget's own authored +`DefaultColor` (`LabelAuthoredColor`), matching CT1's live-DAT pin exactly — +the former "runtime color, dat carries none" comment was false. PK status now +resolves through StringTable `0x23000001` by key with a bitwise IsPK/IsPKLite +test (the prior exact-equality switch silently dropped combined-flag +values); **live-DAT-verified authored strings**: `ID_StatManagement_Header_PKStatus_PK` +→ "Player Killer", `_PKL` → "Player Killer Lite", `_NPK` → "Non-Player Killer" +(pinned in `CharacterPanelLiveDatTests.PkStatusKeys_ResolveExpectedAuthoredStrings`). +Level shows `"%d"`-formatted `InqInt(0x19)` or the PE-recovered literal +`"???"` when absent (`CharacterSheet.Level` is now `int?`). The heritage +line's appended title now comes from CT2/CT3's `RuntimeCharacterTitleState.DisplayTitleId` +resolved through `CharacterTitleResolver`, refreshing live on both +`TableReplaced` and `DisplayTitleChanged` (`CharacterSheetProvider`'s +`ChangeBinding` now subscribes to both). **Name-line ruling:** ships the +PLAIN-NAME case only — retail's allegiance rank-title prefix +(`AllegianceData::GetFullName @0x005b6950` → `AllegianceSystem::GetTitle +@0x005b8dd0`) needs a ~200-string, 22-function heritage×gender table +(verbatim hardcoded literals in the decomp, not DAT-resolved — e.g. +`GetAluvianMaleTitle @0x005b7bc0`'s "Yeoman"/"Baronet"/"Baron"/"Reeve"/ +"Thane"/"Ealdor"/"Duke"/"Aetheling"/"King"/"High King") judged out of +reasonable size for this slice; `RuntimeAllegianceState` already carries the +local player's own rank, so only the string table is missing. **Luminance +(item 5):** the DATA (`CharacterSheet.AvailableLuminance`/`MaximumLuminance`, +PropertyInt64 6/7) already flows generically through both the +PlayerDescription snapshot parser and the live `0x02CF` private-update path +— no wiring gap existed — and the retail show/hide gate +(`Level >= 200 && MaximumLuminance != 0`, `UpdateExperience @0x004F0A70`) is +wired and toggles `Visible` on both `0x100005C5`/`0x100005C6`, but the +label's caption and the value's composed number format could not be +recovered this slice (retail's `SetText` source resolves through a +Binary-Ninja-mislabeled data pointer, not a StringTable key; a DAT +string-table sweep found no match) — content stays unbound rather than +guessed. AP-109 narrowed accordingly (register row updated in the same +commit, not deleted — the two open items above remain). Tests: +`CharacterStatControllerTests` (heritage composition + live update, name +stays plain, level int/"???" with authored — not constant — color, PK line +shows resolved text in authored color, luminance visibility across five +level/luminance combinations) and `CharacterSheetProviderTests` (PK +key-by-status resolution including a combined-flag case, no-resolver ⇒ null, +Level null-vs-present, title resolution + live refresh on both title +events + unsubscribe-on-dispose, luminance Int64 read-through). + **CT5 — Row alignment + value gutter.** Reconcile our hand-built attribute/skill rows with the authored row templates from CT1: icon placement, name/value columns, the authored right margin that reserves diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 6bd8a257..c51a1f27 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -527,6 +527,19 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory var cursorManager = new RetailCursorManager(d.Dats, d.DatLock); checkpoint(InteractionRetainedUiCompositionPoint.CursorAssetsCreated); + // 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. + // CT4 (2026-08-24) also feeds this resolver's display-title text + // into the character panel's own heritage line (CharacterSheet.Title). + var characterTitleResolver = new CharacterTitleResolver(d.Dats); + // CT4: the header identity block's PK-status line (StringTable + // 0x23000001, ID_StatManagement_Header_PKStatus_* keys — the same + // compute_str_hash mechanism ChatWindowController's chatStrings + // delegate already uses). One instance, same DatLock discipline + // as characterTitleResolver above. + var characterUiStrings = new DatStringResolver(d.Dats); var characterSheet = new CharacterSheetProvider( d.Inventory.Objects, d.Character.LocalPlayer, @@ -553,12 +566,17 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory late.GameRuntime.Advance( 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); + credits), + titles: d.Character.Titles, + resolveDisplayTitle: titleId => + { + lock (d.DatLock) return characterTitleResolver.Resolve(titleId); + }, + resolveUiString: key => + { + lock (d.DatLock) + return characterUiStrings.Resolve(0x23000001u, DatStringResolver.ComputeHash(key)); + }); checkpoint(InteractionRetainedUiCompositionPoint.CharacterSheetCreated); uint MagicSkillLevel(MagicSchool school) diff --git a/src/AcDream.App/UI/Layout/CharacterIdentityText.cs b/src/AcDream.App/UI/Layout/CharacterIdentityText.cs index 0b983ca8..f57a0d41 100644 --- a/src/AcDream.App/UI/Layout/CharacterIdentityText.cs +++ b/src/AcDream.App/UI/Layout/CharacterIdentityText.cs @@ -4,8 +4,37 @@ namespace AcDream.App.UI.Layout; /// Retail character identity display helpers for gmStatManagementUI. /// Sources: gmStatManagementUI::UpdateCharacterInfo (0x004f0770) calls /// AppraisalSystem::InqGenderHeritageDisplay(gender 0x71, heritage 0xBC, 0), -/// then appends the current CharacterTitleTable title when one is active. +/// then — when CharacterTitleTable::GetCharacterTitleFromID(m_titleID) +/// resolves — AppendText(separator @data_794358) + AppendText(titleString). +/// Campaign CT slice CT4 (2026-08-24) PE-read RECOVERED the separator as a +/// single space " " (both here and for the allegiance-rank prefix below); +/// 's existing string.Join(" ", ...) already +/// matched it. /// +/// +/// Name-line ruling (CT4, 2026-08-24). Retail's NAME line +/// (AllegianceData::GetFullName @0x005b6950) prefixes an allegiance +/// RANK title ("<RankTitle> <Name>", same space separator, PE-read +/// @data_794098) when AllegianceSystem::GetTitle(rank, heritage, gender) +/// @0x005b8dd0 resolves one. +/// (Campaign FA) DOES carry the local player's own rank +/// (ApplyUpdate's _rank, seeded by 0x0020 +/// AllegianceUpdate — always the local tree), so the DATA half exists. +/// The STRING half does not: GetTitle dispatches on heritage×gender +/// into 22 separate functions (GetAluvianMaleTitle @0x005b7bc0, +/// GetAluvianFemaleTitle @0x005b7cd0, … one per heritage/gender pair +/// through Undead), each a rank-indexed switch over ~10 HARDCODED literal +/// strings (Aluvian male: "Yeoman"/"Baronet"/"Baron"/"Reeve"/"Thane"/ +/// "Ealdor"/"Duke"/"Aetheling"/"King"/"High King" — verbatim from the +/// decomp, not DAT-resolved, not guessed) — roughly 200 title strings +/// total. That is not "reasonable size" for this slice on top of its other +/// four items, so 's Name label +/// ships the PLAIN-NAME case only (matching the owner's own retail +/// screenshot, a rankless character, and every current test character). +/// The missing rank-prefix path is registered +/// (docs/architecture/retail-divergence-register.md) rather than +/// silently omitted. +/// internal static class CharacterIdentityText { public const uint GenderPropertyId = 0x71u; diff --git a/src/AcDream.App/UI/Layout/CharacterSheet.cs b/src/AcDream.App/UI/Layout/CharacterSheet.cs index 43aece82..08c84b91 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheet.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheet.cs @@ -24,8 +24,15 @@ public sealed class CharacterSheet /// Character name (first line of the report). public string Name { get; init; } = string.Empty; - /// Character level. - public int Level { get; init; } + /// + /// Character level. Null when retail PropertyInt 0x19 is absent — Campaign + /// CT slice CT4: gmStatManagementUI::UpdateCharacterInfo + /// (0x004f0770) shows the literal "???" (data_7b0f34, PE-recovered) + /// in that case rather than an integer; a bare formatted level uses + /// "%d" semantics (data_7a0184) — see + /// 's binding. + /// + public int? Level { get; init; } /// Gender display string, e.g. "Female". Null = omit. public string? Gender { get; init; } @@ -58,6 +65,20 @@ public sealed class CharacterSheet /// 0x10000233 (m_pPKStatusText). Null = omit. public string? PkStatus { get; init; } + /// + /// Campaign CT slice CT4: available Luminance points (retail + /// PropertyInt64 6, AvailableLuminance). Header element + /// 0x100005C5/0x100005C6 pair — shown only past level 200 with a + /// nonzero + /// (gmStatManagementUI::UpdateExperience 0x004f0a70's luminance + /// branch). + /// + public long AvailableLuminance { get; init; } + + /// Retail PropertyInt64 7, MaximumLuminance. See + /// . + public long MaximumLuminance { get; init; } + // ── Birth / age / deaths (UpdatePlayerBirthAgeDeaths 0x004b8cb0) ───────── /// Formatted birth date string (retail InqInt(0x62) → strftime). diff --git a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs index 5d73dba4..32b20f60 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using AcDream.App.Net; using AcDream.Core.Items; using AcDream.Core.Player; +using AcDream.Runtime.Gameplay; using DatReaderWriter; using AcDream.Content; @@ -48,6 +49,31 @@ public sealed class CharacterSheetProvider private readonly Action? _sendRaiseSkill; private readonly Action? _sendTrainSkill; + /// Campaign CT slice CT2's title owner — its + /// DisplayTitleId feeds the CT4 heritage-line composition. + /// Null (tests, no live session) leaves + /// null. + private readonly RuntimeCharacterTitleState? _titles; + + /// CT2's CharacterTitleTable::GetCharacterTitleFromID DAT + /// chain ( in production, + /// DatLock-wrapped by the host). Returns null (retail's hardcoded + /// "Unknown" substitution belongs to the Titles-page controller, + /// not the header — the header line simply omits an unresolved title) + /// when the id doesn't resolve. + private readonly Func? _resolveDisplayTitle; + + /// + /// Campaign CT slice CT4: retail StringInfo lookup through + /// StringTable 0x23000001 by key (ID_StatManagement_Header_PKStatus_* + /// — gmStatManagementUI::UpdatePKStatus 0x004f00a0), the same + /// compute_str_hash mechanism ChatWindowController's + /// chatStrings delegate uses. Null (tests) or a resolution miss + /// both leave null — no invented + /// English fallback for this specific line (CT4 contract). + /// + private readonly Func? _resolveUiString; + /// Portal SkillTable (0x0E000004) — set by the host once dats load. public DatReaderWriter.DBObjs.SkillTable? SkillTable { get; set; } @@ -64,7 +90,10 @@ public sealed class CharacterSheetProvider Action? sendRaiseAttribute = null, Action? sendRaiseVital = null, Action? sendRaiseSkill = null, - Action? sendTrainSkill = null) + Action? sendTrainSkill = null, + RuntimeCharacterTitleState? titles = null, + Func? resolveDisplayTitle = null, + Func? resolveUiString = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _localPlayer = localPlayer ?? throw new ArgumentNullException(nameof(localPlayer)); @@ -76,6 +105,9 @@ public sealed class CharacterSheetProvider _sendRaiseVital = sendRaiseVital; _sendRaiseSkill = sendRaiseSkill; _sendTrainSkill = sendTrainSkill; + _titles = titles; + _resolveDisplayTitle = resolveDisplayTitle; + _resolveUiString = resolveUiString; } /// @@ -114,7 +146,14 @@ public sealed class CharacterSheetProvider return _fallbackSheet?.Invoke(CharacterName()) ?? new CharacterSheet { Name = CharacterName() }; var props = CurrentPlayerProperties(); + // #431/CT4: retail's own PropertyInt 0x19 read (InqInt) — the header + // level's "???" fallback (CharacterSheet.Level's own doc comment) + // needs to distinguish "absent" from "present but zero", so this + // stays a raw dictionary probe rather than GetInt's zero-defaulting + // helper. The XP-curve math below still wants a concrete int, so it + // keeps using the 0-defaulted local. int level = props.GetInt(0x19u); + int? displayLevel = props.Ints.ContainsKey(0x19u) ? level : null; long totalXp = props.GetInt64(1u); long unassignedXp = props.GetInt64(UnassignedXpPropertyId); var xp = ComputeLevelXp(level, totalXp); @@ -124,15 +163,28 @@ public sealed class CharacterSheetProvider return new CharacterSheet { Name = CharacterName(), - Level = level, + Level = displayLevel, Gender = CharacterIdentityText.GenderDisplayName( props.GetInt(CharacterIdentityText.GenderPropertyId)), Heritage = CharacterIdentityText.HeritageGroupDisplayName( props.GetInt(CharacterIdentityText.HeritageGroupPropertyId)), - PkStatus = PkStatusText(props.GetInt(134u, 0)), + // CT2/CT3's resolved display title — CT4's heritage line appends + // this (CharacterIdentityText.StatHeaderLine). + Title = _titles is not null && _resolveDisplayTitle is not null + ? _resolveDisplayTitle(_titles.DisplayTitleId) + : null, + PkStatus = PkStatusText(props.GetInt(134u, 0), _resolveUiString), TotalXp = totalXp, XpToNextLevel = xp.toNext, XpFraction = xp.fraction, + // CT4 item 5: retail PropertyInt64 6/7 — the private-update + // (0x02CF) and PlayerDescription (0x0013) parsers both already + // copy every Int64 key generically (ReadInt64Table / + // LocalPlayerState.OnInt64PropertyUpdate have no id whitelist), + // so ids 6/7 flow through with zero additional wiring once ACE + // sends them. + AvailableLuminance = props.GetInt64(6u), + MaximumLuminance = props.GetInt64(7u), HealthCurrent = VitalCurrent(LocalPlayerState.VitalKind.Health), HealthMax = VitalMax(LocalPlayerState.VitalKind.Health), @@ -243,8 +295,19 @@ public sealed class CharacterSheetProvider // not only on raw property/attribute updates. if (owner._localPlayer.Spellbook is { } spellbook) spellbook.EnchantmentsChanged += OnCleared; + // CT4 contract: the heritage line's appended display title MUST + // refresh live on BOTH RuntimeCharacterTitleState notices — CT2's + // TableReplaced (0x0029, retail's own unconditional Refresh()) and + // DisplayTitleChanged (the display half of 0x002B). + if (owner._titles is { } titles) + { + titles.TableReplaced += OnCleared; + titles.DisplayTitleChanged += OnDisplayTitleChanged; + } } + private void OnDisplayTitleChanged(uint _) => OnCleared(); + private void OnObjectChanged(ClientObject value) { CharacterSheetProvider? owner = _owner; @@ -300,6 +363,11 @@ public sealed class CharacterSheetProvider owner._localPlayer.Changed -= OnVitalChanged; if (owner._localPlayer.Spellbook is { } spellbook) spellbook.EnchantmentsChanged -= OnCleared; + if (owner._titles is { } titles) + { + titles.TableReplaced -= OnCleared; + titles.DisplayTitleChanged -= OnDisplayTitleChanged; + } // Panel unmount resets the one-in-flight raise gate — retail's // awaiting flag lives on the panel instance and dies with it. owner.ReleaseAwaitingRaise(); @@ -496,13 +564,28 @@ public sealed class CharacterSheetProvider private static long ClampToLong(ulong value) => value > long.MaxValue ? long.MaxValue : (long)value; - private static string? PkStatusText(int status) => status switch + /// + /// Campaign CT slice CT4: gmStatManagementUI::UpdatePKStatus + /// (0x004f00a0) — IsPK() tested first, then IsPKLite(), + /// else "neither" resolves the NPK string (retail always shows exactly + /// one of the three; there is no hidden/omitted case). ACE's + /// PlayerKillerStatus is a [Flags] enum (PK=0x04, + /// PKLite=0x40) — a bitwise test matches the derived-boolean retail + /// semantics; the prior exact-equality switch silently showed nothing + /// for any combined-flag value. Text resolves through StringTable + /// 0x23000001 by key () — no hardcoded + /// English fallback; a null resolver or a resolution miss both leave + /// the line empty, matching the CT4 contract's "no invented English". + /// + private static string? PkStatusText(int status, Func? resolveUiString) { - 0x2 => "Non-Player Killer", - 0x4 => "Player Killer", - 0x40 => "Player Killer Lite", - _ => null, - }; + string key = (status & 0x4) != 0 + ? "ID_StatManagement_Header_PKStatus_PK" + : (status & 0x40) != 0 + ? "ID_StatManagement_Header_PKStatus_PKL" + : "ID_StatManagement_Header_PKStatus_NPK"; + return resolveUiString?.Invoke(key); + } /// Unenchanted base attribute value (Ranks + Start). Used for /// — the retail diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index 4f50311d..8c62ddb4 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -70,6 +70,17 @@ public static class CharacterStatController public const uint ListScrollbarId = 0x1000023Eu; // m_pListBox vertical scrollbar gutter public const uint ListDividerId = 0x1000023Fu; // bottom divider above footer + /// Campaign CT slice CT4: the luminance pair + /// (m_pLuminanceLabelText/m_pLuminanceText), shown only past level 200 + /// with nonzero MaximumLuminance — see + /// gmStatManagementUI::UpdateExperience (0x004f0a70)'s luminance + /// branch. The label's own retail caption/value StringInfo could not be + /// recovered this slice (its SetText calls resolve through a + /// Binary-Ninja-mislabeled data pointer, not a StringTable key — see the + /// Bind method's own remarks); only the show/hide gate is wired here. + public const uint LuminanceLabelId = 0x100005C5u; + public const uint LuminanceValueId = 0x100005C6u; + // ── Footer STATE-A container id ────────────────────────────────────────── // 0x10000240 is the "nothing selected" footer group. Its children (0x1000024E label row, // 0x10000242–0x10000245 labels+values) are the correct State-A versions with wider @@ -111,7 +122,10 @@ public static class CharacterStatController public const uint RaiseTenId = 0x100005EBu; // raise × 10 private static readonly Vector4 Body = new(0.92f, 0.90f, 0.82f, 1f); // parchment-white body text - private static readonly Vector4 Gold = new(1f, 0.82f, 0.36f, 1f); // section / emphasis gold + // Campaign CT slice CT4 (2026-08-24): the former hand-picked "Gold" + // header-level color constant is deleted — CT1's live-DAT pin confirmed + // the level element authors its own pale-gold FontColor (+ Outline); + // LabelAuthoredColor reads it from the widget instead. /// Row highlight color — semi-translucent gold, matches retail /// UIStateId.Highlight (0x06) sprite 0x06001397 visual intent. @@ -292,11 +306,22 @@ public static class CharacterStatController // Name (18px from dat FontDid), Heritage (14px), PkStatus (14px): // Fix C: pass null → Label's null-guard keeps the build-time dat font. - // Controllers still own the text color and the LinesProvider. - // Name = WHITE (retail "Horan" is white — confirmed 2026-06-26). - Label(layout, contentPage, NameId, null, Vector4.One, () => data().Name); - Label(layout, contentPage, HeritageId, null, Body, () => CharacterIdentityText.StatHeaderLine(data())); - Label(layout, contentPage, PkStatusId, null, Body, () => data().PkStatus ?? string.Empty); + // Campaign CT slice CT4 (2026-08-24): CT1's live-DAT pin + // (HeaderElements_AuthorExpectedFontsAndColors) confirmed all FOUR + // header identity elements — Name, Heritage, PkStatus, Level — carry + // their own authored FontColor (white/white/white/pale-gold with + // Outline). The "runtime color, dat carries none" reasoning this + // block used to justify a hand-picked Body/Gold constant per element + // was FALSIFIED by that pin: every element below now sources its + // color from the widget's own DAT-set DefaultColor + // (LabelAuthoredColor), matching the "authored color/font wins" + // pattern CT3's CharacterTitlesController already established for + // its row/display text. Level's Outline is likewise already applied + // at import time (DatWidgetFactory.BuildText reads dat property + // 0x21) — no controller-side Outline flag needed. + LabelAuthoredColor(layout, contentPage, NameId, null, () => data().Name); + LabelAuthoredColor(layout, contentPage, HeritageId, null, () => CharacterIdentityText.StatHeaderLine(data())); + LabelAuthoredColor(layout, contentPage, PkStatusId, null, () => data().PkStatus ?? string.Empty); // ── Header captions (new — retail labels above/left of each number) ────── // LevelCaption (0x1000023A, 16px from dat): pass null → keep build-time dat font. @@ -305,12 +330,13 @@ public static class CharacterStatController // Level number: retail renders this as large gold centered text in the 65×50 element. // Fix C: the dat FontDid for the level element (0x1000023B) is now applied at build // time when the font resolver is provided (studio path). We no longer force rowDatFont - // here for the level — the dat's own FontDid drives the font. The Gold color is still - // set via LinesProvider. SYNTHESIZED elements (the 9 attribute rows built in - // BuildAttributeRows) continue to use datFont directly since they have no dat origin. - // Source: spec §Level area (65,50) + decomp gmStatManagementUI::UpdateCharacterInfo 0x004f0770. - // runtime color, dat carries none. - Label(layout, contentPage, LevelId, null, Gold, () => data().Level.ToString()); + // here for the level — the dat's own FontDid drives the font. + // Source: spec §Level area (65,50) + decomp gmStatManagementUI::UpdateCharacterInfo + // 0x004f0770. CT4 contract (PE-recovered 2026-08-24): InqInt(0x19) present formats + // with "%d" semantics (a bare integer, data_7a0184); absent shows the literal "???" + // (data_7b0f34) — CharacterSheet.Level is null in that case. + LabelAuthoredColor(layout, contentPage, LevelId, null, + () => data().Level is int lvl ? lvl.ToString(CultureInfo.InvariantCulture) : "???"); // TotalXpLabel (16px from dat) + TotalXp (16px from dat): pass null → keep dat font. LabelLeft(layout, contentPage, TotalXpLabelId, null, Body, static () => "Total Experience (XP):"); @@ -367,6 +393,32 @@ public static class CharacterStatController } } + // ── Luminance pair (0x100005C5/C6) — CT4 item 5 ─────────────────────── + // gmStatManagementUI::UpdateExperience (0x004f0a70): InqInt64(6) + // (AvailableLuminance) and InqInt64(7) (MaximumLuminance) are read + // unconditionally, but the pair is hidden — UIElement_Text::ClearAllText + // on BOTH m_pLuminanceLabelText and m_pLuminanceText — whenever + // "InqInt(0x19) < 0xc8 (200) || MaximumLuminance == 0". Only the + // gate is ported this slice: the label's caption and the value's + // composed "available / maximum" string both resolve through a + // SetText call whose source string BN mislabels as a vftable slot + // (not a StringTable key like the PK line) — recovering the exact + // literal needs a PE-byte-decode pass this slice didn't budget for + // (register row: AP-109 narrows to exactly this). Content is + // intentionally left unbound (blank) rather than guessed; only + // Visible is toggled, so a level-200+ character sees an empty + // (not wrong) pair until a follow-up slice fills it in. + UiElement? luminanceLabel = FindElementByDatId(layout, contentPage, LuminanceLabelId); + UiElement? luminanceValue = FindElementByDatId(layout, contentPage, LuminanceValueId); + void RefreshLuminanceVisibility() + { + var sheet = data(); + bool visible = sheet.Level is int lvl && lvl >= 200 && sheet.MaximumLuminance != 0; + if (luminanceLabel is not null) luminanceLabel.Visible = visible; + if (luminanceValue is not null) luminanceValue.Visible = visible; + } + RefreshLuminanceVisibility(); + // The tab visuals are already retained in the imported LayoutDesc. Controllers // bind only click behavior and the active Open/Closed state below. @@ -656,6 +708,11 @@ public static class CharacterStatController } RefreshActiveRaiseButtons(); + // CT4: the luminance gate reads Level/MaximumLuminance off the + // CURRENT sheet, so it must re-run on every sheet-changed refresh + // (level-up, a luminance-award quality change), not only at bind + // time. + RefreshLuminanceVisibility(); } return () => RefreshAfterRaise(null); @@ -2009,6 +2066,31 @@ public static class CharacterStatController } } + /// + /// Same binding shape as , but the per-line color is + /// read from the widget's own — the + /// value DatWidgetFactory.BuildText already seeded from the + /// element's authored dat property 0x1B — instead of a caller-supplied + /// constant. Campaign CT slice CT4 (2026-08-24): the header identity + /// block's four elements (Name/Heritage/PkStatus/Level) all carry their + /// own correct authored color (CT1's live-DAT pin), so "authored color + /// wins" here is both simpler and more correct than hand-picking a + /// runtime constant — the same precedent + /// 's row/display text already + /// set (rowText.DefaultColor). + /// + private static void LabelAuthoredColor(ImportedLayout layout, UiElement? scope, uint id, UiDatFont? datFont, Func text) + { + if (FindTextByDatId(layout, scope, id) is UiText t) + { + if (datFont is not null) t.DatFont = datFont; + t.Centered = true; + t.OneLine = true; + t.ClickThrough = true; + t.LinesProvider = () => new[] { new UiText.Line(text(), t.DefaultColor) }; + } + } + /// Two-line centered label. Provides TWO lines from LinesProvider so both /// fit side-by-side in a narrow element without truncation. The scroll path in /// renders multiple lines oldest-first (top-to-bottom), so diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs index 65589b48..5a87693c 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs @@ -387,4 +387,33 @@ public sealed class CharacterPanelLiveDatTests string? resolved = resolver.Resolve(0x2300000Eu, hash); Assert.Equal("War Mage", resolved); } + + /// + /// Campaign CT slice CT4: gmStatManagementUI::UpdatePKStatus + /// (0x004f00a0) resolves its three-way PK status text through + /// StringTable 0x23000001 by key (the same compute_str_hash + /// mechanism ChatWindowController's chat labels already use — no + /// EnumMapper indirection needed here, unlike the title chain above). + /// Pins the exact authored strings CharacterSheetProvider.PkStatusText + /// resolves against, discovered by a live probe against the installed + /// DAT set (not guessed): "Player Killer" / "Player Killer Lite" / + /// "Non-Player Killer". + /// + [InstalledDatFact] + public void PkStatusKeys_ResolveExpectedAuthoredStrings() + { + using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read); + var resolver = new DatStringResolver(dats); + + string? pk = resolver.Resolve(0x23000001u, + DatStringResolver.ComputeHash("ID_StatManagement_Header_PKStatus_PK")); + string? pkLite = resolver.Resolve(0x23000001u, + DatStringResolver.ComputeHash("ID_StatManagement_Header_PKStatus_PKL")); + string? npk = resolver.Resolve(0x23000001u, + DatStringResolver.ComputeHash("ID_StatManagement_Header_PKStatus_NPK")); + + Assert.Equal("Player Killer", pk); + Assert.Equal("Player Killer Lite", pkLite); + Assert.Equal("Non-Player Killer", npk); + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs index 1a8a42d7..94931d08 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs @@ -5,6 +5,7 @@ using AcDream.Core.Items; using AcDream.Core.Player; using AcDream.Core.Properties; using AcDream.Core.Spells; +using AcDream.Runtime.Gameplay; using Xunit; namespace AcDream.App.Tests.UI.Layout; @@ -420,4 +421,167 @@ public sealed class CharacterSheetProviderTests Assert.Equal(0, changed); } + + // ── Campaign CT slice CT4: PK status / display title / luminance ─────── + + /// + /// gmStatManagementUI::UpdatePKStatus (0x004f00a0): IsPK() tested + /// first, then IsPKLite(), else "neither" resolves NPK. The resolver + /// stub below echoes the KEY it was handed, so the assertion proves + /// which of the three ID_StatManagement_Header_PKStatus_* keys + /// was selected for each ACE PlayerKillerStatus value — the CT4 + /// contract's "PK line resolves the three keys by status". + /// + [Theory] + [InlineData(0x4, "ID_StatManagement_Header_PKStatus_PK")] + [InlineData(0x40, "ID_StatManagement_Header_PKStatus_PKL")] + [InlineData(0x2, "ID_StatManagement_Header_PKStatus_NPK")] // plain NPK bit + [InlineData(0x0, "ID_StatManagement_Header_PKStatus_NPK")] // Undef — still resolves NPK, not omitted + // Bitwise test (not the prior exact-equality switch): PK combined with + // an unrelated flag (Unprotected, 0x08) still resolves PK — IsPK() is + // true regardless of the other bits. + [InlineData(0x4 | 0x8, "ID_StatManagement_Header_PKStatus_PK")] + public void BuildSheet_PkStatus_ResolvesCorrectKeyByStatus(int rawStatus, string expectedKey) + { + var objects = new ClientObjectTable(); + var player = new LocalPlayerState(); + string? capturedKey = null; + var provider = new CharacterSheetProvider( + objects, player, + playerGuid: () => PlayerGuid, + resolveUiString: key => + { + capturedKey = key; + return key; // echo — the test asserts on the KEY, not invented English + }); + + var obj = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" }; + obj.Properties.Ints[134u] = rawStatus; + objects.AddOrUpdate(obj); + + CharacterSheet sheet = provider.BuildSheet(); + + Assert.Equal(expectedKey, capturedKey); + Assert.Equal(expectedKey, sheet.PkStatus); + } + + /// CT4 contract: "no invented English; if a key fails to + /// resolve, show nothing" — a null resolver (no live DAT session, e.g. + /// the Studio path) must not synthesize any PK text. + [Fact] + public void BuildSheet_PkStatus_NoResolver_LeavesPkStatusNull() + { + var objects = new ClientObjectTable(); + var player = new LocalPlayerState(); + var provider = new CharacterSheetProvider(objects, player, playerGuid: () => PlayerGuid); + + var obj = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" }; + obj.Properties.Ints[134u] = 0x4; // PK + objects.AddOrUpdate(obj); + + Assert.Null(provider.BuildSheet().PkStatus); + } + + /// CT4 contract item 4: Level is null (not 0) when retail + /// InqInt(0x19) is absent, distinguishing "no property yet" from a + /// genuinely-present value. + [Fact] + public void BuildSheet_Level_NullWhenPropertyAbsent_PresentOtherwise() + { + var objects = new ClientObjectTable(); + var player = new LocalPlayerState(); + var provider = new CharacterSheetProvider(objects, player, playerGuid: () => PlayerGuid); + + var obj = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" }; + obj.Properties.Ints[0x18u] = 1; // some OTHER property present so HasLiveData() is true + objects.AddOrUpdate(obj); + Assert.Null(provider.BuildSheet().Level); + + obj.Properties.Ints[0x19u] = 42; + objects.AddOrUpdate(obj); + Assert.Equal(42, provider.BuildSheet().Level); + } + + /// + /// CT4 item 2: the heritage line's appended title comes from CT2's + /// resolved + /// through the DAT id->string chain — + /// tracks whatever the resolver returns for the CURRENT display id. + /// + [Fact] + public void BuildSheet_Title_ResolvesDisplayTitleIdThroughResolver() + { + var objects = new ClientObjectTable(); + var player = new LocalPlayerState(); + var titles = new RuntimeCharacterTitleState(); + var provider = new CharacterSheetProvider( + objects, player, + playerGuid: () => PlayerGuid, + titles: titles, + resolveDisplayTitle: id => id == 13u ? "War Mage" : null); + + var obj = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" }; + obj.Properties.Ints[0x19u] = 1; // some property present so HasLiveData() is true + objects.AddOrUpdate(obj); + + Assert.Null(provider.BuildSheet().Title); // no display title seeded yet + + titles.ReplaceTable(13u, new uint[] { 13u }); + + Assert.Equal("War Mage", provider.BuildSheet().Title); + } + + /// + /// CT4 contract: the heritage line MUST refresh live on both + /// (0x0029) and + /// (the + /// display half of 0x002B) — both must fire the sheet-changed + /// notification + /// exposes, and both must stop firing after disposal. + /// + [Fact] + public void SubscribeChanged_FiresOnTitlesTableReplacedAndDisplayTitleChanged_AndUnsubscribesOnDispose() + { + var objects = new ClientObjectTable(); + var player = new LocalPlayerState(); + var titles = new RuntimeCharacterTitleState(); + var provider = new CharacterSheetProvider( + objects, player, playerGuid: () => PlayerGuid, titles: titles); + int changed = 0; + IDisposable subscription = provider.SubscribeChanged(() => changed++); + + titles.ReplaceTable(1u, new uint[] { 1u }); // TableReplaced (+ DisplayTitleChanged, id 0->1) + Assert.True(changed >= 1); + + int afterFirst = changed; + titles.ApplyUpdateTitle(2u, setAsDisplay: true); // UpdateTitle → DisplayTitleChanged (1->2) + Assert.True(changed > afterFirst); + + subscription.Dispose(); + int afterDispose = changed; + titles.ReplaceTable(3u, new uint[] { 3u }); + Assert.Equal(afterDispose, changed); + } + + /// CT4 item 5: retail PropertyInt64 6 (AvailableLuminance) / 7 + /// (MaximumLuminance) flow into the sheet exactly like TotalXp/ + /// UnassignedXp — the same generic, non-whitelisted Int64 property + /// path. + [Fact] + public void BuildSheet_Luminance_ReadsInt64Properties6And7() + { + var objects = new ClientObjectTable(); + var player = new LocalPlayerState(); + var provider = new CharacterSheetProvider(objects, player, playerGuid: () => PlayerGuid); + + var obj = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" }; + obj.Properties.Int64s[6u] = 1_500_000L; + obj.Properties.Int64s[7u] = 25_000_000L; + objects.AddOrUpdate(obj); + + var sheet = provider.BuildSheet(); + + Assert.Equal(1_500_000L, sheet.AvailableLuminance); + Assert.Equal(25_000_000L, sheet.MaximumLuminance); + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index 4b8b07eb..5b0833e1 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -157,6 +157,146 @@ public class CharacterStatControllerTests Assert.Empty(hiddenXpNext.LinesProvider()); } + // ── Campaign CT slice CT4: header identity block ─────────────────────── + + /// CT4 item 2: the heritage line composes Gender + Heritage + + /// " " + the resolved display title (CharacterIdentityText.StatHeaderLine), + /// and — because the label's text() provider re-reads data() + /// on every draw — reflects a live title change with no rebind, exactly + /// how CharacterSheetProvider's own DisplayTitleChanged/ + /// TableReplaced subscription drives a real sheet rebuild in + /// production. + [Fact] + public void Bind_HeritageLine_ComposesGenderHeritageTitle_AndUpdatesLiveOnDisplayTitleChange() + { + var heritage = new UiText(); + var layout = Fake((CharacterStatController.HeritageId, heritage)); + CharacterSheet sheet = new() { Gender = "Female", Heritage = "Aluvian" }; + + CharacterStatController.Bind(layout, () => sheet); + + Assert.Equal("Female Aluvian", heritage.LinesProvider()[0].Text); + + // Simulates RuntimeCharacterTitleState.DisplayTitleChanged firing and + // CharacterSheetProvider rebuilding the sheet with the newly resolved + // title — CharacterStatController never rebinds, the label's own + // provider just re-reads the (reassigned) sheet. + sheet = new CharacterSheet { Gender = "Female", Heritage = "Aluvian", Title = "War Mage" }; + Assert.Equal("Female Aluvian War Mage", heritage.LinesProvider()[0].Text); + } + + /// CT4 item 1 ruling: the Name line ships the PLAIN-NAME case + /// only — retail's allegiance rank-title prefix + /// (AllegianceData::GetFullName @0x005b6950) needs a ~200-string + /// 22-function heritage×gender table judged out of reasonable size for + /// this slice (AP-109). The Name label must show exactly + /// , with no rank prefix synthesized + /// from anywhere. + [Fact] + public void Bind_NameLine_ShowsPlainNameOnly_NoRankPrefix() + { + var name = new UiText(); + var layout = Fake((CharacterStatController.NameId, name)); + + CharacterStatController.Bind(layout, () => new CharacterSheet { Name = "Dww" }); + + Assert.Equal("Dww", name.LinesProvider()[0].Text); + } + + /// CT4 item 4: the level shows a bare "%d"-formatted + /// integer when is present, and the + /// PE-recovered literal "???" when it is null (retail InqInt(0x19) + /// absent). Both cases use the WIDGET's own authored + /// — not a hardcoded constant — proving + /// the CT1 "authored color/font wins" fix (the former "Gold" constant is + /// deleted from the controller entirely). + [Theory] + [InlineData(126, "126")] + [InlineData(0, "0")] + [InlineData(null, "???")] + public void Bind_LevelLine_FormatsIntegerOrShowsQuestionMarks_InAuthoredColor(int? level, string expectedText) + { + var authoredColor = new Vector4(0.11f, 0.22f, 0.33f, 1f); + var levelText = new UiText { DefaultColor = authoredColor }; + var layout = Fake((CharacterStatController.LevelId, levelText)); + + CharacterStatController.Bind(layout, () => new CharacterSheet { Level = level }); + + UiText.Line line = Assert.Single(levelText.LinesProvider()); + Assert.Equal(expectedText, line.Text); + Assert.Equal(authoredColor, line.Color); + } + + /// CT4 item 3: the PK line shows exactly whatever + /// carries (the resolved + /// StringTable text — see CharacterSheetProviderTests for the + /// three-key resolution itself) in the widget's own authored color, not + /// the deleted parchment "Body" constant. + [Theory] + [InlineData("Player Killer")] + [InlineData("Player Killer Lite")] + [InlineData("Non-Player Killer")] + public void Bind_PkStatusLine_ShowsResolvedText_InAuthoredColor(string resolvedText) + { + var authoredColor = new Vector4(0.4f, 0.5f, 0.6f, 1f); + var pk = new UiText { DefaultColor = authoredColor }; + var layout = Fake((CharacterStatController.PkStatusId, pk)); + + CharacterStatController.Bind(layout, () => new CharacterSheet { PkStatus = resolvedText }); + + UiText.Line line = Assert.Single(pk.LinesProvider()); + Assert.Equal(resolvedText, line.Text); + Assert.Equal(authoredColor, line.Color); + } + + /// An unresolved PK key (CT4 contract: "no invented English; if + /// a key fails to resolve, show nothing") shows an empty line rather + /// than a fabricated English fallback. + [Fact] + public void Bind_PkStatusLine_NullPkStatus_ShowsEmptyText() + { + var pk = new UiText(); + var layout = Fake((CharacterStatController.PkStatusId, pk)); + + CharacterStatController.Bind(layout, () => new CharacterSheet { PkStatus = null }); + + Assert.Equal(string.Empty, pk.LinesProvider()[0].Text); + } + + /// CT4 item 5: the luminance pair (0x100005C5/0x100005C6) + /// toggles Visible per retail's exact gate — UpdateExperience + /// (0x004f0a70): "InqInt(0x19) < 200 || MaximumLuminance == 0" hides + /// both elements; otherwise both show. The elements' own content is left + /// unbound this slice (register row AP-109) — only visibility is + /// asserted here. + [Theory] + [InlineData(126, 0L, false)] // below level 200 — hidden regardless of luminance + [InlineData(200, 0L, false)] // level gate met, but MaximumLuminance == 0 — hidden + [InlineData(200, 1_000_000L, true)] // both conditions met — visible + [InlineData(275, 500L, true)] + [InlineData(null, 500L, false)] // absent level — treated as "not level 200+" + public void Bind_LuminancePair_TogglesVisibility_PerRetailGate(int? level, long maxLuminance, bool expectedVisible) + { + var label = new UiDatElement( + new ElementInfo { Id = CharacterStatController.LuminanceLabelId, Type = 3 }, + static _ => (0u, 0, 0)); + var value = new UiDatElement( + new ElementInfo { Id = CharacterStatController.LuminanceValueId, Type = 3 }, + static _ => (0u, 0, 0)); + var layout = Fake( + (CharacterStatController.LuminanceLabelId, label), + (CharacterStatController.LuminanceValueId, value)); + + CharacterStatController.Bind(layout, () => new CharacterSheet + { + Level = level, + MaximumLuminance = maxLuminance, + }); + + Assert.Equal(expectedVisible, label.Visible); + Assert.Equal(expectedVisible, value.Visible); + } + // ── XP meter fill ──────────────────────────────────────────────────────── [Fact]