diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 26f05f47..3ed8bdff 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -361,7 +361,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | ~~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 FURTHER 2026-08-25 at the Campaign CT4 fix round — the luminance pair's TEXT is now bound and the PK classification now reads the live PWD bits, closing both out of this row.** `CharacterSheetProvider.PkStatusText` classifies off `ClientObject.PublicWeenieBitfield` bits `0x20` (IsPK) / `0x02000000` (IsPKLite) — the exact `ACCWeenieObject::IsPK @0x0058c8b0` / `IsPKLite @0x0058c8a0` PWD-bitfield reads, ported already at `PlayerKillerStatusBitfield.Apply` (#297) — instead of the CT4-landed bitwise test against raw PropertyInt 134 (a non-retail mapping: PropertyInt 134 carries ACE's own `PlayerKillerStatus` enum values, not the PWD bit layout). `CharacterStatController`'s luminance pair (`0x100005C5`/`0x100005C6`) now binds real text: caption `"Luminance:"` (UTF-16, PE-byte-recovered from the `gmStatManagementUI` vftable-adjacent data region at `@0x007c3dd4`) and value `" / "` (narrow `"%s / %s"` format, PE-byte-recovered at `@0x007c3dcc`, args in that order per `UpdateExperience`'s call sequence — `ExperienceSystem::XPToString(AvailableLuminance, ...)` then `XPToString(MaximumLuminance, ...)`), both numbers formatted through the same shared `FormatXp` helper the Total XP / XP-to-level fields use (`.ToString("N0", CultureInfo.InvariantCulture)` — the C# equivalent of retail's `XPToString`→`GetNumberFormatA` locale-grouped-decimal call; not a byte-identical Win32 port, so an exotic edge case, e.g. negative/overflow, is this row's own residual sliver if one is ever found). The hide path is retail's own `UIElement_Text::ClearAllText` (`@0x004f0e31`/`@0x004f0e3c` — empties `LinesProvider` content, leaves layout) rather than `Visible = false`. (CT3's Titles-page narrowing, restored here verbatim after CT4's edit compressed it to a bare pointer phrase, still stands:) **CT3's narrowing (2026-08-24), verbatim:** `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). Campaign CT slice CT4 (2026-08-24) then put the header identity block live: `CharacterStatController`'s Name/Heritage/PkStatus/Level labels 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 VERBATIM (`CharacterIdentityText.StatHeaderLine`, CT4-fix-round-corrected 2026-08-25 to stop stripping a leading "The " — retail `AppendText`s the resolved title unmodified at `@0x004f0990`, and 26 real ACE `CharacterTitle` entries begin with "The"); the level shows `"%d"`-formatted `InqInt(0x19)` or the literal `"???"` when absent (both PE-recovered). One item remains open, registered rather than silently dropped: the NAME line ships the PLAIN-NAME case only — retail's allegiance-rank prefix (`AllegianceData::GetFullName @0x005b6950` → `AllegianceSystem::GetTitle @0x005b8dd0`) needs a ~170-string, **17-function** [CORRECTED 2026-08-25 from CT4's original 22-function/~200-string estimate — `GetTitle`'s own dispatch switch (`@0x005b8dd0`) was read directly: Gearknight and Tumerok author only a MALE `Get*Title` function, reused for both genders' dispatch branches, and Lugian authors only a FEMALE one, reused for both — 11 heritages produce 17 functions, not 22 (2 each for Aluvian/Gharu'ndim/Sho/Viamontian/Shadowbound/Empyrean/Undead, 1 each for Gearknight/Tumerok/Lugian); Olthoi/OlthoiAcid (heritage ids 12/13) have no title function at all — `GetTitle`'s own range check `(heritage-1) <= 0xa` (unsigned) excludes them, and heritage id `0xa` (Penumbraen) aliases to the Shadowbound functions] 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. The RANK value is PropertyInt `0x1E` (`AllegianceRank`) read LIVE off the qualities bundle (`CBaseQualities::InqInt(qualities, 0x1e)` — ACE actively pushes this property on every allegiance-rank change) [CORRECTED 2026-08-25 — CT4's original text claimed `RuntimeAllegianceState` "already carries the local player's own rank," conflating this row's context with `SocialAllegiancePageController`'s OWN, DIFFERENT, already-documented substitution (that controller has no qualities-bundle access, so it renders `RuntimeAllegianceSnapshot.Rank` — same `0x0020 AllegianceUpdate` wire message, numerically equivalent in every observed case — as its own accepted stand-in). `CharacterSheetProvider.BuildSheet` already reads every other header property straight off `props.GetInt(...)` from the qualities-equivalent `PropertyBundle`, so the correct future port reads `props.GetInt(0x1Eu)` directly, not `RuntimeAllegianceState` — only the STRING table is missing, not the data]. | `src/AcDream.App/UI/Layout/CharacterStatController.cs` (`LabelAuthoredColor`, `RefreshLuminanceVisibility`, `FormatXp`); `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` (`PkStatusText`); `src/AcDream.Core/Items/ClientObject.cs` (`PlayerKillerStatusBitfield`); `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; evidence for the header-identity block is synthetic-layout binding tests plus a small number of InstalledDat string/DID pins (the three PK strings, the gender/heritage EnumMapper chain at AP-235) — not a connected/live gate | A ranked-allegiance character's Name line shows plain name only (no title prefix) until the 17-function table is ported | `gmStatManagementUI::UpdateCharacterInfo @ 0x004F0770`; `UpdatePKStatus @ 0x004F00A0`; `UpdateExperience @ 0x004F0A70`; `UIElement_Text::ClearAllText @ 0x004F0E31`/`0x004F0E3C`; `ACCWeenieObject::IsPK @ 0x0058C8B0`; `IsPKLite @ 0x0058C8A0`; `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-110 | **NARROWED 2026-08-25 (Campaign AS, the assess/examination-window retail-parity campaign) — "exhaustive character detail regions" retired from the still-lacks list.** AS2 ported the player header identity block (composed gender+heritage, current display title, PK status from the local weenie's PWD bits, allegiance name); AS3 ported the per-bodypart armor-level trio (with the `*` unenchantable sentinel), the ratings-family spacer discipline, and the unconditional `* = Unenchantable` legend; AS4 ported the remaining extras-list rows — society/faction (rank bands, local-vs-target faction color rule), the Monarch/Patron/Followers cascade, and the seven configurable extras (Fellowship, Arrived in Dereth, Time in Dereth, Chess Rank, Fishing Skill, Deaths, Titles Earned) — closing the character-path extras list end to end. See `docs/research/2026-08-25-campaign-as-ground-truth.md` for the full row-by-row decomp citations; the row's OTHER residuals (item-object preview, effective shield projection, cooldown-remaining, augmentation-cost `StringInfo`, creature FontInfo-list selection) are untouched by this campaign and remain open below. **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`, 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`; `CharExamineUI::SetAppraiseInfo @ 0x004B45F0`; `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 | | AP-163 | **REVIEW CORRECTION 2026-08-09 (Opus review of `97cf8738`, finding F1):** this row's ownership discipline is now COMPLETE on both halves, not just the add-time collision guard described below. The retire pass (`OnVendorTransition`'s loop over guids missing from the new `ApproachVendor` snapshot) previously deleted ANY such guid unconditionally — a plain bug, not a documented divergence, since buying a UNIQUE vendor item re-containers that SAME guid into the buyer's own pack (`Player_Commerce.cs:86-108`) BEFORE the post-buy refresh that drops it from the shop's own list arrives; the old retire pass would have stripped the just-purchased item straight back out of the buyer's inventory. **The exact rule now enforced:** each owned guid remembers the vendor id it was registered under (`Dictionary`, guid -> vendorId), and the retire pass calls `ClientObjectTable.Remove` ONLY when the live object's CURRENT `ContainerId` still equals that recorded vendor id; when it differs (or the object is already gone), the tracking entry is dropped silently and the object itself is left completely untouched — the SAME skip-not-clobber discipline the add-time collision guard below already used, now applied symmetrically on the way out. This is a bug fix, not a new divergence, and does not change this row's still-open scope: retail's actual `ClientObjMaintSystem`/`CObjectMaint` collision behavior on a guid collision remains untraced. **Filed 2026-08-09, Slice 6.1 (shop-item materialization).** `VendorShopItemMaterializer` registers each `ApproachVendor` shop item into `ClientObjectTable` keyed by its own server guid. ACE's `UniqueItemsForSale` (`Vendor.cs:34,638`) can list the EXACT guid a player last held (an item sold to this vendor keeps its original guid), so a guid collision against an existing, differently-owned `ClientObjectTable` entry is a real, if rare, possibility. No retail behavior for this exact case was traced (retail's `ClientObjMaintSystem`/`CObjectMaint` guid-keyed registration internals were not decompiled for this pass). acdream's policy is a conscious, conservative default: a guid this materializer did NOT itself add to the table on a previous cycle is treated as owned by something else and is left completely untouched — never overwritten, never later removed by this class. | `src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs` (`OnVendorTransition`'s collision guard) | Skip-not-clobber is the safe default absent a traced retail mechanism: silently reparenting a live entity's or another container's item into the vendor's `ContainerId` would corrupt real ownership state (equipment tracking, burden, radar) for a guid this code does not own, which is strictly worse than a single shop row's status-bar/appraisal projection staying blank. The vendor list itself is unaffected either way — `VendorUiController` reads display fields straight off `VendorShopItem`, never through `ClientObjectTable`. | If retail's actual behavior differs (e.g. it always overwrites, or a real `UniqueItemsForSale` collision is more common than assumed), the one colliding shop row's status-bar/appraisal projection stays stale/blank instead of showing the vendor listing — a narrow, single-row display gap, never a corrupted non-vendor object. Retiring this row requires tracing retail's `ClientObjMaintSystem` registration behavior on a guid collision, which was out of scope for this pass. | No direct retail citation traced this pass — `Vendor.cs:34,638` (`UniqueItemsForSale`, ACE) establishes the collision is POSSIBLE, not what retail does about it; `docs/research/2026-08-08-slice6-vendor-transactions-research.md` (task brief: "study how ACE guids vendor stock and state your collision policy with evidence") | diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index c51a1f27..116ff7ba 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -17,6 +17,7 @@ using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Net.Messages; using AcDream.Core.Player; +using AcDream.Core.Properties; using AcDream.Core.Selection; using AcDream.Core.Spells; using AcDream.Runtime; @@ -928,7 +929,14 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory item, inscription), text => - d.Communication.AddText(text, RetailLogTextType.ClientLocal)), + d.Communication.AddText(text, RetailLogTextType.ClientLocal), + // AS4 (gap G6): the SAME LocalPlayerState instance + // characterSheet was built from above — no new state + // path, just another read of its PlayerDescription- + // backed property snapshot. + LocalFactionBits: () => + d.Character.LocalPlayer.Properties.GetInt( + (uint)PropertyInt.Faction1Bits)), Options: new OptionsRuntimeBindings( CommandBus: () => late.Session.Commands, // Tri-state per gmGamePlayUI::UseTime @0x004EA3A0's exact diff --git a/src/AcDream.App/UI/Layout/AppraisalUiController.cs b/src/AcDream.App/UI/Layout/AppraisalUiController.cs index bac95ade..0cf1b56f 100644 --- a/src/AcDream.App/UI/Layout/AppraisalUiController.cs +++ b/src/AcDream.App/UI/Layout/AppraisalUiController.cs @@ -92,6 +92,17 @@ public sealed class AppraisalUiController : IRetainedPanelController /// Template every time, matching retail's own fallback branch. /// private readonly Func _resolveCharacterTitle; + /// + /// AS4 (gap G6): the LOCAL player's own Faction1Bits (PropertyInt 281), + /// resolved PER CALL under the same deferred-Func discipline as + /// — never captured once at mount + /// time. Feeds 's Society + /// row color rule (ground truth §2b row 1). Defaults to 0 (factionless) + /// so a caller that supplies nothing renders the Society row exactly as + /// if the local player belonged to no society, matching retail's own + /// "local has no bits" branch. + /// + private readonly Func _localFactionBits; private readonly SpellExamineComponentTemplateFactory? _spellComponentTemplates; private readonly UiText _spellSchool; private readonly UiText _spellMana; @@ -148,7 +159,8 @@ public sealed class AppraisalUiController : IRetainedPanelController Func>? spellComponents, Func? magicSkill, SpellExamineComponentTemplateFactory? spellComponentTemplates, - Func? resolveCharacterTitle) + Func? resolveCharacterTitle, + Func? localFactionBits) { _layout = layout; _objects = objects; @@ -176,6 +188,7 @@ public sealed class AppraisalUiController : IRetainedPanelController _spellComponents = spellComponents ?? (_ => []); _magicSkill = magicSkill ?? (_ => 0u); _resolveCharacterTitle = resolveCharacterTitle ?? (_ => null); + _localFactionBits = localFactionBits ?? (() => 0); _spellComponentTemplates = spellComponentTemplates; _spellSchool = (UiText)layout.FindElement(SpellSchoolTextId)!; _spellMana = (UiText)layout.FindElement(SpellManaTextId)!; @@ -305,7 +318,8 @@ public sealed class AppraisalUiController : IRetainedPanelController Func>? spellComponents = null, Func? magicSkill = null, SpellExamineComponentTemplateFactory? spellComponentTemplates = null, - Func? resolveCharacterTitle = null) + Func? resolveCharacterTitle = null, + Func? localFactionBits = null) { ArgumentNullException.ThrowIfNull(layout); ArgumentNullException.ThrowIfNull(objects); @@ -358,7 +372,8 @@ public sealed class AppraisalUiController : IRetainedPanelController spellComponents, magicSkill, spellComponentTemplates, - resolveCharacterTitle); + resolveCharacterTitle, + localFactionBits); } /// @@ -822,6 +837,9 @@ public sealed class AppraisalUiController : IRetainedPanelController /// (RefreshCurrentAppraisal) round-trips a /// brand-new response through , so no separate cache /// is needed for a refresh to keep rendering the same armor-level rows. + /// AS4: is only invoked on the character + /// path — the monster path never renders a Society row and has no use + /// for it. /// private void RebuildCreatureStats( AppraiseInfoParser.Parsed appraisal, @@ -836,7 +854,10 @@ public sealed class AppraisalUiController : IRetainedPanelController : Array.Empty()); _creatureExtra?.Rebuild( CreatureAppraisalRows.BuildExtra( - appraisal.Properties, appraisal.ArmorLevels, character)); + appraisal.Properties, + appraisal.ArmorLevels, + character, + character ? _localFactionBits() : 0)); } private void ConfigureScrollableText( diff --git a/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs b/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs index db38926a..ae34464e 100644 --- a/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs +++ b/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs @@ -3,6 +3,8 @@ using System.Numerics; using AcDream.Content; using AcDream.Core.Items; using AcDream.Core.Net.Messages; +using AcDream.Core.Properties; +using AcDream.Core.Ui; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; @@ -48,6 +50,49 @@ public static class CreatureAppraisalRows private const uint DotResistRating = 0x15Eu; private const uint LifeResistRating = 0x15Fu; + // ── Campaign AS slice AS4: society/allegiance/fellowship + configurable + // extras (G6/G7/G8). Ground truth: docs/research/2026-08-25-campaign-as- + // ground-truth.md §2b rows 1-2 and 8-14. Decomp anchors: CharExamineUI:: + // SetAppraiseInfo @0x004B45F0 — Society @0x004b49a1-0x004b4c24 (color + // rule sourced from the LOCAL player's own Faction1Bits, fetched a few + // lines earlier at @0x004b4971-0x004b4984 via CBaseQualities::InqInt); + // Monarch/Patron/Followers @0x004b4d97-0x004b4f54; configurable extras + // Fellowship @0x004b58be, Arrived in Dereth @0x004b593a, Time in Dereth + // @0x004b59bc, Chess Rank @0x004b5a5b, Fishing Skill @0x004b5afa, Deaths + // @0x004b5b96, Titles Earned @0x004b5c4d — all CHARACTER-path only; the + // monster path (CreatureExamineUI::SetAppraiseInfo @0x004B3FF0) ends + // after its own ratings trailing spacer and never reaches any of this. + private const uint Faction1BitsProperty = (uint)PropertyInt.Faction1Bits; // 281 + private const uint SocietyRankCelestialHandProperty = + (uint)PropertyInt.SocietyRankCelhan; // 287 + private const uint SocietyRankEldrytchWebProperty = + (uint)PropertyInt.SocietyRankEldweb; // 288 + private const uint SocietyRankRadiantBloodProperty = + (uint)PropertyInt.SocietyRankRadblo; // 289 + private const int CelestialHandBit = 0x1; + private const int EldrytchWebBit = 0x2; + private const int RadiantBloodBit = 0x4; + private const int SocietyBitsMask = + CelestialHandBit | EldrytchWebBit | RadiantBloodBit; + + private const uint AllegianceRankProperty = (uint)PropertyInt.AllegianceRank; // 30 + private const uint AllegianceFollowersProperty = + (uint)PropertyInt.AllegianceFollowers; // 35 (Int table) + private const uint MonarchsTitleProperty = + (uint)PropertyString.MonarchsTitle; // 21 (String table) + private const uint PatronsTitleProperty = + (uint)PropertyString.PatronsTitle; // 35 (String table) + + private const uint FellowshipProperty = (uint)PropertyString.Fellowship; // 10 + private const uint DateOfBirthProperty = (uint)PropertyString.DateOfBirth; // 43 + private const uint AgeProperty = (uint)PropertyInt.Age; // 125 + private const uint ChessRankProperty = (uint)PropertyInt.ChessRank; // 181 + private const uint FishingSkillProperty = + (uint)PropertyInt.FakeFishingSkill; // 192 + private const uint NumDeathsProperty = (uint)PropertyInt.NumDeaths; // 43 (Int table) + private const uint NumCharacterTitlesProperty = + (uint)PropertyInt.NumCharacterTitles; // 262 + public static IReadOnlyList Build( AppraiseInfoParser.CreatureProfile profile, bool success) @@ -98,15 +143,26 @@ public static class CreatureAppraisalRows private const int UnenchantableArmorLevel = 9999; /// - /// Port of the armor-level trio + rating rows from + /// Port of the FULL extras list from /// CharExamineUI::SetAppraiseInfo @ 0x004B45F0 (player path) and /// CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0 (monster path, - /// ratings only — no armor-level trio and no unenchantable legend; the - /// monster function ends immediately after its own trailing ratings - /// spacer and never touches base_armor_* or the - /// u"* = Unenchantable" literal). Ground truth: - /// docs/research/2026-08-25-campaign-as-ground-truth.md §2b rows - /// 3-7 + 15, ruling R3 (legend unconditional) and R4 (spacer discipline). + /// ratings only — no society, no allegiance, no armor-level trio, no + /// configurable extras, no unenchantable legend; the monster function + /// ends immediately after its own trailing ratings spacer and never + /// touches any of that). Ground truth: + /// docs/research/2026-08-25-campaign-as-ground-truth.md §2b (the + /// complete row table), rulings R3 (legend unconditional) and R4 (spacer + /// discipline). + /// + /// Row order (character path only): Society (gap G6) → Monarch/Patron/ + /// Followers (gap G7) → armor-level trio → ratings block → configurable + /// extras: Fellowship/Arrived in Dereth/Time in Dereth/Chess Rank/ + /// Fishing Skill/Deaths/Titles Earned (gap G8) → the + /// * = Unenchantable legend, ALWAYS the last row + /// (@0x004b5d7d-@0x004b5ded). No separators exist between the + /// configurable-extra rows in the decomp — only the ratings family gets + /// the leading/trailing spacer treatment described below. + /// /// /// Rating gating/spacer logic: retail's CharExamineUI decompile /// mangles its leading-spacer flag (ebx_13) into unreadable @@ -129,19 +185,38 @@ public static class CreatureAppraisalRows /// /// /// for the CharExamineUI (player) path, - /// which alone emits the armor-level trio and the trailing - /// "* = Unenchantable" legend; for the - /// CreatureExamineUI (monster) path, which never emits either. + /// which alone emits society/allegiance, the armor-level trio, the + /// configurable extras, and the trailing "* = Unenchantable" legend; + /// for the CreatureExamineUI (monster) + /// path, which never emits any of them. + /// + /// + /// The LOCAL player's own Faction1Bits (PropertyInt 281), fetched by + /// retail via CBaseQualities::InqInt on the local qualities + /// object a few lines before the Society row builds + /// (@0x004b4971-@0x004b4984). The composer stays pure — callers source + /// this from whatever seam exposes the local player's live properties + /// (see 's binding). Defaults to 0 + /// (factionless) for callers — mostly monster-path or pre-AS4 tests — + /// that never touch the Society row. /// public static IReadOnlyList BuildExtra( PropertyBundle properties, AppraiseInfoParser.ArmorLevel? armorLevels, - bool character) + bool character, + int localFactionBits = 0) { ArgumentNullException.ThrowIfNull(properties); var rows = new List(); + if (character) + { + AddSocietyRow(rows, properties, localFactionBits); + if (Get(properties, AllegianceRankProperty) >= 1) + AddAllegianceCascade(rows, properties); + } + if (character && armorLevels is { } levels && HasAnyArmorLevel(levels)) { rows.Add(Blank()); @@ -196,6 +271,8 @@ public static class CreatureAppraisalRows if (character) { + AddConfigurableExtras(rows, properties); + rows.Add(new CreatureAppraisalRow( "* = Unenchantable", string.Empty, @@ -205,6 +282,222 @@ public static class CreatureAppraisalRows return rows; } + /// + /// Port of the Society row @0x004b49a1-@0x004b4c24 (gap G6). Gated on + /// PropertyInt 281 Faction1Bits being PRESENT on the response (matches + /// AppraisalProfile::InqInt's found/not-found return, not a + /// value-nonzero test — a slightly more literal reading than the ground + /// truth doc's informal "≠ 0" phrasing). Bit-priority order (Celestial + /// Hand 0x1, then Eldrytch Web 0x2, then — ONLY when neither of those + /// AND Radiant Blood's 0x4 bit is also clear — the unrecognized + /// "???" fallback (data_7af4e8), else Radiant Blood) comes + /// straight from the decompiled if/else-if chain; a target with more + /// than one bit set resolves to whichever bit the chain checks first. + /// The unrecognized branch never sets a color (retail's ebx_3 + /// stays at its zero initializer) — always + /// . + /// + private static void AddSocietyRow( + List rows, + PropertyBundle properties, + int localFactionBits) + { + if (!properties.Ints.TryGetValue(Faction1BitsProperty, out int targetBits)) + return; + + string name; + int rank; + int targetBit; + if ((targetBits & CelestialHandBit) != 0) + { + name = "Celestial Hand"; + rank = Get(properties, SocietyRankCelestialHandProperty); + targetBit = CelestialHandBit; + } + else if ((targetBits & EldrytchWebBit) != 0) + { + name = "Eldrytch Web"; + rank = Get(properties, SocietyRankEldrytchWebProperty); + targetBit = EldrytchWebBit; + } + else if ((targetBits & RadiantBloodBit) == 0) + { + rows.Add(new CreatureAppraisalRow( + "Society:", Unknown, CreatureAppraisalValueStyle.Normal)); + return; + } + else + { + name = "Radiant Blood"; + rank = Get(properties, SocietyRankRadiantBloodProperty); + targetBit = RadiantBloodBit; + } + + rows.Add(new CreatureAppraisalRow( + "Society:", + name + SocietyRankSuffix(rank), + SocietyColor(targetBit, localFactionBits))); + } + + /// + /// Rank-band suffix bounds @0x004b4ab9-@0x004b4b92 — five bands, each + /// inclusive on both ends; outside every band (including rank 0, the + /// value seen when InqInt on the rank property fails) the society + /// name stands alone with no suffix. + /// + private static string SocietyRankSuffix(int rank) => rank switch + { + >= 1 and <= 100 => " ~ Initiate", + >= 101 and <= 300 => " ~ Adept", + >= 301 and <= 600 => " ~ Knight", + >= 601 and <= 1000 => " ~ Lord", + >= 1001 and <= 1500 => " ~ Master", + _ => string.Empty, + }; + + /// + /// The Society color rule: green when the local player shares the + /// TARGET's selected bit (checked first, so a local player with + /// multiple bits set still resolves green if one of them matches); + /// red when the local player has neither of the target's bit but has + /// at least one of the other two society bits; normal (no color) when + /// the local player has no society bits at all. Same + /// InqInt-into-CBaseQualities read for all three branches + /// @0x004b49fd/@0x004b4a49/@0x004b4a8b. + /// + private static CreatureAppraisalValueStyle SocietyColor( + int targetBit, int localFactionBits) + { + if ((localFactionBits & targetBit) != 0) + return CreatureAppraisalValueStyle.Positive; + if ((localFactionBits & (SocietyBitsMask & ~targetBit)) != 0) + return CreatureAppraisalValueStyle.Negative; + return CreatureAppraisalValueStyle.Normal; + } + + /// + /// Port of the Monarch/Patron/Followers cascade @0x004b4d97-@0x004b4f54 + /// (gap G7), gated by the caller on AllegianceRank >= 1 (the SAME + /// var_108 InqInt read retail's header AllegianceName binding + /// already consumes — see ). Four arms, tested in retail's exact + /// order: MonarchsTitle (Str 21) absent → follower-count row; present + + /// PatronsTitle (Str 35) absent → Monarch-only row; both present and + /// EQUAL (ordinal — server-composed strings, no case-folding in the + /// decomp) → one combined row; both present and different → two rows. + /// + private static void AddAllegianceCascade( + List rows, PropertyBundle properties) + { + if (!properties.Strings.TryGetValue( + MonarchsTitleProperty, out string? monarchsTitle)) + { + int followers = Get(properties, AllegianceFollowersProperty); + if (followers < 0) + followers = 0; + string unit = followers == 1 ? "Follower" : "Followers"; + rows.Add(new CreatureAppraisalRow( + "Alleg. Monarch:", + $"{Number(followers)} {unit}", + CreatureAppraisalValueStyle.Normal)); + return; + } + + if (!properties.Strings.TryGetValue( + PatronsTitleProperty, out string? patronsTitle)) + { + rows.Add(new CreatureAppraisalRow( + "Monarch:", monarchsTitle, CreatureAppraisalValueStyle.Normal)); + return; + } + + if (string.Equals(monarchsTitle, patronsTitle, StringComparison.Ordinal)) + { + rows.Add(new CreatureAppraisalRow( + "Monarch/Patron:", + monarchsTitle, + CreatureAppraisalValueStyle.Normal)); + return; + } + + rows.Add(new CreatureAppraisalRow( + "Monarch:", monarchsTitle, CreatureAppraisalValueStyle.Normal)); + rows.Add(new CreatureAppraisalRow( + "Patron:", patronsTitle, CreatureAppraisalValueStyle.Normal)); + } + + /// + /// Port of the seven configurable-extras rows (gap G8, ground truth §2b + /// rows 8-14): Fellowship @0x004b58be, Arrived in Dereth @0x004b593a, + /// Time in Dereth @0x004b59bc, Chess Rank @0x004b5a5b, Fishing Skill + /// @0x004b5afa, Deaths @0x004b5b96, Titles Earned @0x004b5c4d. Each row + /// is independently gated on its OWN property being present on the + /// response — ACE already strips these per the TARGET's own visibility + /// options before the response is sent + /// (AppraiseInfo.cs:352-366), so no client-side option logic or + /// success gating belongs here. No spacers separate these rows in the + /// decomp. Time in Dereth reuses the ALREADY-PORTED + /// — the exact same + /// ClientUISystem::DeltaTimeToString @0x00565E10 the decomp calls + /// here (@0x004b59e0) — rather than porting a duplicate. + /// + private static void AddConfigurableExtras( + List rows, PropertyBundle properties) + { + if (properties.Strings.TryGetValue( + FellowshipProperty, out string? fellowship)) + { + rows.Add(new CreatureAppraisalRow( + "Fellowship:", fellowship, CreatureAppraisalValueStyle.Normal)); + } + + if (properties.Strings.TryGetValue( + DateOfBirthProperty, out string? arrived)) + { + rows.Add(new CreatureAppraisalRow( + "Arrived in Dereth:", + arrived, + CreatureAppraisalValueStyle.Normal)); + } + + if (properties.Ints.TryGetValue(AgeProperty, out int ageSeconds)) + { + rows.Add(new CreatureAppraisalRow( + "Time in Dereth:", + RetailDurationText.Format(ageSeconds), + CreatureAppraisalValueStyle.Normal)); + } + + if (properties.Ints.TryGetValue(ChessRankProperty, out int chessRank)) + { + rows.Add(new CreatureAppraisalRow( + "Chess Rank:", Number(chessRank), CreatureAppraisalValueStyle.Normal)); + } + + if (properties.Ints.TryGetValue(FishingSkillProperty, out int fishingSkill)) + { + rows.Add(new CreatureAppraisalRow( + "Fishing Skill:", + Number(fishingSkill), + CreatureAppraisalValueStyle.Normal)); + } + + if (properties.Ints.TryGetValue(NumDeathsProperty, out int deaths)) + { + rows.Add(new CreatureAppraisalRow( + "Deaths:", + deaths <= 0 ? "Has never died" : Number(deaths), + CreatureAppraisalValueStyle.Normal)); + } + + if (properties.Ints.TryGetValue( + NumCharacterTitlesProperty, out int titles)) + { + rows.Add(new CreatureAppraisalRow( + "Titles Earned:", Number(titles), CreatureAppraisalValueStyle.Normal)); + } + } + private static bool HasAnyArmorLevel(AppraiseInfoParser.ArmorLevel levels) => levels.Head > 0 || levels.Chest > 0 || levels.Abdomen > 0 || levels.UpperArm > 0 || levels.LowerArm > 0 || levels.Hand > 0 diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 0e9cdc48..f8b77788 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -411,7 +411,15 @@ public sealed record ConfirmationRuntimeBindings( public sealed record AppraisalRuntimeBindings( Func PlayerName, Action SendSetInscription, - Action DisplaySystemMessage); + Action DisplaySystemMessage, + // Campaign AS slice AS4 (gap G6): the LOCAL player's own Faction1Bits + // (PropertyInt 281), feeding the examination window's Society row color + // rule (CreatureAppraisalRows.BuildExtra). Sourced from the SAME + // LocalPlayerState reference CharacterSheetProvider is built from + // (d.Character.LocalPlayer in InteractionRetainedUiComposition) — no new + // state path, just another read of the existing PlayerDescription-backed + // property snapshot. + Func LocalFactionBits); public sealed record VendorRuntimeBindings( VendorState State, @@ -2147,7 +2155,8 @@ public sealed class RetailUiRuntime : IDisposable spellComponents: _bindings.Magic.SpellComponents, magicSkill: _bindings.Magic.MagicSkill, spellComponentTemplates: spellComponentTemplates, - resolveCharacterTitle: ResolveCharacterTitle); + resolveCharacterTitle: ResolveCharacterTitle, + localFactionBits: _bindings.Appraisal.LocalFactionBits); if (controller is null) { Console.WriteLine( diff --git a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs index 73366bb6..7eb2f7a2 100644 --- a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs @@ -891,14 +891,13 @@ public sealed class AppraisalUiControllerTests interaction.ExamineSelectedOrEnterMode(ObjectId); var properties = new PropertyBundle(); properties.Strings[5u] = "Template"; - var armorLevels = new AppraiseInfoParser.ArmorLevel( + var firstArmorLevels = new AppraiseInfoParser.ArmorLevel( Head: 50, Chest: 60, Abdomen: 70, UpperArm: 80, LowerArm: 90, Hand: 100, UpperLeg: 110, LowerLeg: 120, Foot: 130); - AppraiseInfoParser.Parsed appraisal = Parsed( - properties, MinimalCreatureProfile(), armorLevels: armorLevels); - Assert.True(controller.Apply(appraisal)); + Assert.True(controller.Apply(Parsed( + properties, MinimalCreatureProfile(), armorLevels: firstArmorLevels))); controller.OnShown(); int sentBeforeRefresh = sent.Count; @@ -907,20 +906,142 @@ public sealed class AppraisalUiControllerTests // The 0.75 s combat refresh fired exactly one fresh wire request. Assert.Equal(sentBeforeRefresh + 1, sent.Count); - // The refreshed response is a brand-new Parsed value coming back - // through Apply — AppraiseInfoParser always parses ArmorLevels when - // the flag is set, so nothing needs to be cached client-side for - // the re-applied response to keep rendering the same AL rows. - Assert.True(controller.Apply(appraisal)); + // AS3 NIT 11: the refresh brings back a GENUINELY DIFFERENT response + // (e.g. gear swapped mid-fight) — proving the extras list re-renders + // from the fresh data rather than replaying a cached copy of the + // first response. + var secondArmorLevels = new AppraiseInfoParser.ArmorLevel( + Head: 51, Chest: 61, Abdomen: 71, + UpperArm: 81, LowerArm: 91, Hand: 101, + UpperLeg: 111, LowerLeg: 121, Foot: 131); + Assert.True(controller.Apply(Parsed( + properties, MinimalCreatureProfile(), armorLevels: secondArmorLevels))); + + UiItemList extraAfterSecond = CreatureExtraList(layout); + Assert.Equal(5, extraAfterSecond.GetNumUIItems()); + Assert.Equal( + ("Head/Chest/Groin", "AL: 51/61/71"), ExtraRow(extraAfterSecond, 1)); + Assert.Equal( + ("Bicep/Wrist/Hand", "AL: 81/91/101"), ExtraRow(extraAfterSecond, 2)); + Assert.Equal( + ("Thigh/Shin/Foot", "AL: 111/121/131"), ExtraRow(extraAfterSecond, 3)); + + // A THIRD response carrying no ArmorLevels blob at all (e.g. a + // response that simply didn't set the flag) clears the trio + // entirely rather than leaving the second response's rows stuck + // on screen. + Assert.True(controller.Apply(Parsed( + properties, MinimalCreatureProfile(), armorLevels: null))); + + UiItemList extraAfterThird = CreatureExtraList(layout); + Assert.Equal(1, extraAfterThird.GetNumUIItems()); + Assert.Equal( + ("* = Unenchantable", string.Empty), ExtraRow(extraAfterThird, 0)); + } + + // ── Campaign AS slice AS4: society/allegiance/fellowship + configurable + // extras (G6/G7/G8). Real LayoutDesc/template binding through the + // localFactionBits seam (AppraisalRuntimeBindings.LocalFactionBits in + // production), matching the AS2/AS3 controller-level pattern. + + [Fact] + public void CharacterResponse_SocietyAllegianceAndFellowshipRenderThroughRealBinding() + { + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Dww", + Type = ItemType.Creature, + }); + using var interaction = NewInteraction(objects, []); + var templates = new CreatureAppraisalRowTemplateFactory( + FixtureLoader.LoadExaminationRowTemplateInfos(), + NoTexture, + defaultFont: null); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + new CombatState(), + [], + [], + () => { }, + () => { }, + templates, + // The seam AppraisalRuntimeBindings.LocalFactionBits threads in + // production: the local player shares the target's Celestial + // Hand bit. + localFactionBits: () => 0x1)!; + + interaction.ExamineSelectedOrEnterMode(ObjectId); + var properties = new PropertyBundle(); + properties.Strings[5u] = "Template"; // Character-view marker + properties.Ints[281u] = 0x1; // Faction1Bits: Celestial Hand + properties.Ints[287u] = 50; // society rank -> Initiate band + properties.Ints[30u] = 5; // AllegianceRank >= 1 + properties.Strings[21u] = "Monarch Title"; // no PatronsTitle -> Monarch-only row + properties.Strings[10u] = "Fellows"; + + Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile()))); + Assert.Equal(AppraisalView.Character, controller.ActiveView); UiItemList extra = CreatureExtraList(layout); - Assert.Equal(5, extra.GetNumUIItems()); + Assert.Equal(4, extra.GetNumUIItems()); Assert.Equal( - ("Head/Chest/Groin", "AL: 50/60/70"), ExtraRow(extra, 1)); + ("Society:", "Celestial Hand ~ Initiate"), ExtraRow(extra, 0)); + Assert.Equal(("Monarch:", "Monarch Title"), ExtraRow(extra, 1)); + Assert.Equal(("Fellowship:", "Fellows"), ExtraRow(extra, 2)); Assert.Equal( - ("Bicep/Wrist/Hand", "AL: 80/90/100"), ExtraRow(extra, 2)); + ("* = Unenchantable", string.Empty), ExtraRow(extra, 3)); + } + + [Fact] + public void CharacterResponse_LocalFactionBitsDefaultsToFactionlessWhenBindingSuppliesNone() + { + // No localFactionBits argument -> AppraisalUiController's own + // fallback (() => 0), exactly like a caller that never wires the + // binding (matches R7-style defensive defaults elsewhere in this + // controller). + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Dww", + Type = ItemType.Creature, + }); + using var interaction = NewInteraction(objects, []); + var templates = new CreatureAppraisalRowTemplateFactory( + FixtureLoader.LoadExaminationRowTemplateInfos(), + NoTexture, + defaultFont: null); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + new CombatState(), + [], + [], + () => { }, + () => { }, + templates)!; + + interaction.ExamineSelectedOrEnterMode(ObjectId); + var properties = new PropertyBundle(); + properties.Strings[5u] = "Template"; + properties.Ints[281u] = 0x1; // Faction1Bits: Celestial Hand + + Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile()))); + + UiItemList extra = CreatureExtraList(layout); + // Row still renders (Society doesn't gate on the local player); it + // just gets no color because the local player has no matching bit. + // No rank property was set, so there is no band suffix (rank 0 is + // outside every band). Assert.Equal( - ("Thigh/Shin/Foot", "AL: 110/120/130"), ExtraRow(extra, 3)); + ("Society:", "Celestial Hand"), ExtraRow(extra, 0)); } [Fact] @@ -953,14 +1074,24 @@ public sealed class AppraisalUiControllerTests interaction.ExamineSelectedOrEnterMode(ObjectId); // No String 5 / Int 261 marker -> monster path. ArmorLevels present // on the wire (a real ACE response always carries them for a - // successful non-player target too) must still be ignored here. + // successful non-player target too) must still be ignored here, and + // so must AS4's society/allegiance/configurable-extras properties — + // a real ACE response can carry those for a non-attackable monster + // target too (ground truth §3), but CreatureExamineUI:: + // SetAppraiseInfo @0x004B3FF0 never reads any of them. var armorLevels = new AppraiseInfoParser.ArmorLevel( Head: 100, Chest: 110, Abdomen: 120, UpperArm: 130, LowerArm: 140, Hand: 150, UpperLeg: 160, LowerLeg: 170, Foot: 180); + var properties = new PropertyBundle(); + properties.Ints[281u] = 0x1; // Faction1Bits + properties.Ints[30u] = 5; // AllegianceRank + properties.Strings[21u] = "Monarch Title"; + properties.Strings[10u] = "Fellows"; + properties.Ints[43u] = 2; // NumDeaths Assert.True(controller.Apply(Parsed( - new PropertyBundle(), + properties, MinimalCreatureProfile(), armorLevels: armorLevels))); Assert.Equal(AppraisalView.Creature, controller.ActiveView); @@ -1389,7 +1520,8 @@ public sealed class AppraisalUiControllerTests Func>? spellComponents = null, Func? magicSkill = null, SpellExamineComponentTemplateFactory? spellComponentTemplates = null, - Func? resolveCharacterTitle = null) + Func? resolveCharacterTitle = null, + Func? localFactionBits = null) => AppraisalUiController.Bind( layout, objects, @@ -1410,7 +1542,8 @@ public sealed class AppraisalUiControllerTests spellComponents, magicSkill, spellComponentTemplates, - resolveCharacterTitle); + resolveCharacterTitle, + localFactionBits); private static ItemInteractionController NewInteraction( ClientObjectTable objects, @@ -1434,9 +1567,12 @@ public sealed class AppraisalUiControllerTests AppraiseInfoParser.ArmorLevel? armorLevels = null) => new( Guid: guid, - Flags: creature is null + Flags: (creature is null ? AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable - : AppraiseInfoParser.IdentifyResponseFlags.CreatureProfile, + : AppraiseInfoParser.IdentifyResponseFlags.CreatureProfile) + | (armorLevels is null + ? AppraiseInfoParser.IdentifyResponseFlags.None + : AppraiseInfoParser.IdentifyResponseFlags.ArmorLevels), Success: success, Properties: properties, SpellBook: [], diff --git a/tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs b/tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs index cca7ea3a..e6ffa30b 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs @@ -2,6 +2,7 @@ using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.Items; using AcDream.Core.Net.Messages; +using AcDream.Core.Ui; namespace AcDream.App.Tests.UI.Layout; @@ -261,6 +262,334 @@ public sealed class CreatureAppraisalRowsTests Assert.Equal(("* = Unenchantable", string.Empty), (only.Label, only.Value)); } + // ── Campaign AS slice AS4: society/allegiance/fellowship + configurable + // extras (G6/G7/G8). Ground truth: docs/research/2026-08-25-campaign-as- + // ground-truth.md §2b rows 1-2 and 8-14. Decomp anchors: Society + // @0x004b49a1-@0x004b4c24, Monarch/Patron/Followers + // @0x004b4d97-@0x004b4f54, configurable extras + // @0x004b58be-@0x004b5c4d. + + [Fact] + public void SocietyRowAbsentWhenFaction1BitsPropertyNotPresent() + { + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + new PropertyBundle(), armorLevels: null, character: true); + + Assert.DoesNotContain(rows, r => r.Label == "Society:"); + } + + [Theory] + [InlineData(0x1, 287u, "Celestial Hand")] + [InlineData(0x2, 288u, "Eldrytch Web")] + [InlineData(0x4, 289u, "Radiant Blood")] + public void SocietyRowSelectsNameAndRankPropertyPerTargetBit( + int targetBit, uint rankPropertyId, string expectedName) + { + var properties = new PropertyBundle(); + properties.Ints[281u] = targetBit; + properties.Ints[rankPropertyId] = 50; // Initiate band + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + properties, armorLevels: null, character: true); + + CreatureAppraisalRow row = Assert.Single(rows, r => r.Label == "Society:"); + Assert.Equal($"{expectedName} ~ Initiate", row.Value); + } + + [Theory] + [InlineData(0x8)] + [InlineData(0x10)] + public void SocietyRowFallsBackToUnrecognizedForUnknownBitCombinations(int bits) + { + var properties = new PropertyBundle(); + properties.Ints[281u] = bits; + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + properties, armorLevels: null, character: true, localFactionBits: 0x1); + + CreatureAppraisalRow row = Assert.Single(rows, r => r.Label == "Society:"); + Assert.Equal("???", row.Value); + // Retail's ebx_3 color accumulator never gets touched on this branch. + Assert.Equal(CreatureAppraisalValueStyle.Normal, row.Style); + } + + [Theory] + [InlineData(0, "")] + [InlineData(1, " ~ Initiate")] + [InlineData(100, " ~ Initiate")] + [InlineData(101, " ~ Adept")] + [InlineData(300, " ~ Adept")] + [InlineData(301, " ~ Knight")] + [InlineData(600, " ~ Knight")] + [InlineData(601, " ~ Lord")] + [InlineData(1000, " ~ Lord")] + [InlineData(1001, " ~ Master")] + [InlineData(1500, " ~ Master")] + [InlineData(1501, "")] + public void SocietyRankBandSuffixMatchesRetailInclusiveBoundaries( + int rank, string expectedSuffix) + { + var properties = new PropertyBundle(); + properties.Ints[281u] = 0x1; // Celestial Hand + properties.Ints[287u] = rank; + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + properties, armorLevels: null, character: true); + + CreatureAppraisalRow row = Assert.Single(rows, r => r.Label == "Society:"); + Assert.Equal("Celestial Hand" + expectedSuffix, row.Value); + } + + [Theory] + [InlineData(0x1, 0x1, CreatureAppraisalValueStyle.Positive)] + [InlineData(0x1, 0x2, CreatureAppraisalValueStyle.Negative)] + [InlineData(0x1, 0x4, CreatureAppraisalValueStyle.Negative)] + [InlineData(0x1, 0x0, CreatureAppraisalValueStyle.Normal)] + [InlineData(0x2, 0x2, CreatureAppraisalValueStyle.Positive)] + [InlineData(0x2, 0x1, CreatureAppraisalValueStyle.Negative)] + [InlineData(0x4, 0x4, CreatureAppraisalValueStyle.Positive)] + [InlineData(0x4, 0x2, CreatureAppraisalValueStyle.Negative)] + public void SocietyColorReflectsLocalPlayerFactionBitsAgainstTarget( + int targetBit, int localFactionBits, CreatureAppraisalValueStyle expected) + { + var properties = new PropertyBundle(); + properties.Ints[281u] = targetBit; + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + properties, armorLevels: null, character: true, localFactionBits); + + CreatureAppraisalRow row = Assert.Single(rows, r => r.Label == "Society:"); + Assert.Equal(expected, row.Style); + } + + [Fact] + public void SocietyColorPrioritizesSameBitMatchEvenWhenLocalHasOtherBitsToo() + { + var properties = new PropertyBundle(); + properties.Ints[281u] = 0x1; + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + properties, armorLevels: null, character: true, localFactionBits: 0x1 | 0x2); + + CreatureAppraisalRow row = Assert.Single(rows, r => r.Label == "Society:"); + Assert.Equal(CreatureAppraisalValueStyle.Positive, row.Style); + } + + [Fact] + public void AllegianceCascadeAbsentWhenAllegianceRankBelowOne() + { + var properties = new PropertyBundle(); + properties.Ints[30u] = 0; + properties.Strings[21u] = "Should Not Appear"; + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + properties, armorLevels: null, character: true); + + Assert.DoesNotContain(rows, r => r.Label is "Monarch:" or "Patron:" + or "Monarch/Patron:" or "Alleg. Monarch:"); + } + + [Theory] + [InlineData(0, "0 Followers")] + [InlineData(1, "1 Follower")] + [InlineData(2, "2 Followers")] + [InlineData(-5, "0 Followers")] + public void AllegianceCascadeShowsClampedFollowerCountWhenMonarchsTitleAbsent( + int followers, string expected) + { + var properties = new PropertyBundle(); + properties.Ints[30u] = 1; + properties.Ints[35u] = followers; + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + properties, armorLevels: null, character: true); + + CreatureAppraisalRow row = Assert.Single(rows, r => r.Label == "Alleg. Monarch:"); + Assert.Equal(expected, row.Value); + } + + [Fact] + public void AllegianceCascadeShowsMonarchOnlyWhenPatronsTitleAbsent() + { + var properties = new PropertyBundle(); + properties.Ints[30u] = 1; + properties.Strings[21u] = "Baroness Aluvia"; + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + properties, armorLevels: null, character: true); + + Assert.Equal( + "Baroness Aluvia", Assert.Single(rows, r => r.Label == "Monarch:").Value); + Assert.DoesNotContain(rows, r => r.Label is "Patron:" or "Monarch/Patron:"); + } + + [Fact] + public void AllegianceCascadeCombinesMonarchAndPatronWhenTitlesAreOrdinallyEqual() + { + var properties = new PropertyBundle(); + properties.Ints[30u] = 1; + properties.Strings[21u] = "Same Title"; + properties.Strings[35u] = "Same Title"; + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + properties, armorLevels: null, character: true); + + Assert.Equal( + "Same Title", Assert.Single(rows, r => r.Label == "Monarch/Patron:").Value); + Assert.DoesNotContain(rows, r => r.Label is "Monarch:" or "Patron:"); + } + + [Fact] + public void AllegianceCascadeSplitsMonarchAndPatronWhenTitlesDiffer() + { + var properties = new PropertyBundle(); + properties.Ints[30u] = 1; + properties.Strings[21u] = "Monarch Title"; + properties.Strings[35u] = "Patron Title"; + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + properties, armorLevels: null, character: true); + + Assert.Equal( + "Monarch Title", Assert.Single(rows, r => r.Label == "Monarch:").Value); + Assert.Equal( + "Patron Title", Assert.Single(rows, r => r.Label == "Patron:").Value); + Assert.DoesNotContain(rows, r => r.Label == "Monarch/Patron:"); + } + + [Fact] + public void ConfigurableExtrasEachAppearOnlyWhenTheirOwnPropertyIsPresent() + { + var properties = new PropertyBundle(); + properties.Strings[10u] = "The Fellows"; + properties.Strings[43u] = "1/1/2023"; // DateOfBirth (String table) + properties.Ints[125u] = 90; // Age, seconds + properties.Ints[181u] = 7; + properties.Ints[192u] = 42; + properties.Ints[262u] = 3; + // Int 43 (NumDeaths) deliberately absent — separate Int-table id + // from the String-table DateOfBirth id above; must not leak a row. + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + properties, armorLevels: null, character: true); + + Assert.Equal( + "The Fellows", Assert.Single(rows, r => r.Label == "Fellowship:").Value); + Assert.Equal( + "1/1/2023", + Assert.Single(rows, r => r.Label == "Arrived in Dereth:").Value); + Assert.Equal( + RetailDurationText.Format(90), + Assert.Single(rows, r => r.Label == "Time in Dereth:").Value); + Assert.Equal("7", Assert.Single(rows, r => r.Label == "Chess Rank:").Value); + Assert.Equal("42", Assert.Single(rows, r => r.Label == "Fishing Skill:").Value); + Assert.Equal("3", Assert.Single(rows, r => r.Label == "Titles Earned:").Value); + Assert.DoesNotContain(rows, r => r.Label == "Deaths:"); + } + + [Fact] + public void ConfigurableExtrasAllAbsentLeavesOnlyTheLegend() + { + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + new PropertyBundle(), armorLevels: null, character: true); + + CreatureAppraisalRow only = Assert.Single(rows); + Assert.Equal("* = Unenchantable", only.Label); + } + + [Theory] + [InlineData(0, "Has never died")] + [InlineData(-3, "Has never died")] + [InlineData(1, "1")] + [InlineData(5, "5")] + public void DeathsRowShowsHasNeverDiedAtOrBelowZeroButKeepsTheSameLabel( + int deaths, string expected) + { + var properties = new PropertyBundle(); + properties.Ints[43u] = deaths; // NumDeaths (Int table) + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + properties, armorLevels: null, character: true); + + CreatureAppraisalRow row = Assert.Single(rows, r => r.Label == "Deaths:"); + Assert.Equal(expected, row.Value); + } + + [Fact] + public void MonsterPathNeverGainsSocietyAllegianceOrConfigurableExtraRows() + { + var properties = new PropertyBundle(); + properties.Ints[281u] = 0x1; + properties.Ints[30u] = 5; + properties.Strings[21u] = "Monarch Title"; + properties.Strings[10u] = "Fellows"; + properties.Strings[43u] = "1/1/2023"; + properties.Ints[125u] = 90; + properties.Ints[181u] = 7; + properties.Ints[192u] = 42; + properties.Ints[43u] = 2; + properties.Ints[262u] = 3; + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + properties, armorLevels: null, character: false, localFactionBits: 0x1); + + Assert.DoesNotContain(rows, r => r.Label == "Society:"); + Assert.DoesNotContain(rows, r => r.Label is "Monarch:" or "Patron:" + or "Monarch/Patron:" or "Alleg. Monarch:"); + Assert.DoesNotContain(rows, r => r.Label == "Fellowship:"); + Assert.DoesNotContain(rows, r => r.Label == "Arrived in Dereth:"); + Assert.DoesNotContain(rows, r => r.Label == "Time in Dereth:"); + Assert.DoesNotContain(rows, r => r.Label == "Chess Rank:"); + Assert.DoesNotContain(rows, r => r.Label == "Fishing Skill:"); + Assert.DoesNotContain(rows, r => r.Label == "Deaths:"); + Assert.DoesNotContain(rows, r => r.Label == "Titles Earned:"); + Assert.DoesNotContain(rows, r => r.Label == "* = Unenchantable"); + } + + [Fact] + public void CompleteCharacterExtrasOrderingMatchesRetailRowSequence() + { + var properties = new PropertyBundle(); + // Society: local shares the target's Celestial Hand bit -> green. + properties.Ints[281u] = 0x1; + properties.Ints[287u] = 50; + // Allegiance cascade: monarch/patron present and different. + properties.Ints[30u] = 5; + properties.Strings[21u] = "Monarch Title"; + properties.Strings[35u] = "Patron Title"; + // Ratings: only the Dmg/CritDmg family fires. + properties.Ints[0x133u] = 10; + // Configurable extras: only Fellowship and Deaths present. + properties.Strings[10u] = "Fellows"; + properties.Ints[43u] = 0; + var levels = new AppraiseInfoParser.ArmorLevel( + Head: 1, Chest: 0, Abdomen: 0, + UpperArm: 0, LowerArm: 0, Hand: 0, + UpperLeg: 0, LowerLeg: 0, Foot: 0); + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + properties, levels, character: true, localFactionBits: 0x1); + + Assert.Equal( + [ + "Society:", + "Monarch:", + "Patron:", + "", // AL trio leading spacer + "Head/Chest/Groin", + "Bicep/Wrist/Hand", + "Thigh/Shin/Foot", + "", // ratings leading spacer + "Dmg/CritDmg", + "", // ratings trailing spacer + "Fellowship:", + "Deaths:", + "* = Unenchantable", + ], + rows.Select(r => r.Label)); + Assert.Equal(CreatureAppraisalValueStyle.Positive, rows[0].Style); + } + [Fact] public void AuthoredRowTemplateCarriesRetailOverlappingLabelValueGeometry() {