From 4ade9b04279eae047ce4fac9a5f72021cf63ec7a Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 09:45:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Campaign=20AS=20AS4=20=E2=80=94=20s?= =?UTF-8?q?ociety/allegiance/fellowship=20+=20configurable=20extras=20(G6/?= =?UTF-8?q?G7/G8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the last three retail extras-list row families from CharExamineUI:: SetAppraiseInfo @0x004B45F0 into CreatureAppraisalRows.BuildExtra, closing the character-path extras list end to end (AS2 header + AS3 armor-level/ ratings/legend + AS4 here). All rows are CHARACTER-path only; the monster path (CreatureExamineUI::SetAppraiseInfo @0x004B3FF0) is unaffected and pinned by a controller-level regression test. Society row (gap G6, @0x004b49a1-@0x004b4c24): gated on PropertyInt 281 (Faction1Bits) being PRESENT — a literal reading of InqInt's found/not- found return, not the ground-truth doc's informal "!= 0" value test. Bit-priority if/else-if chain (Celestial Hand 0x1 -> Eldrytch Web 0x2 -> "???" when Radiant Blood's 0x4 bit is ALSO clear -> else Radiant Blood) comes straight off the decompiled branches. Rank-band suffix boundaries (1-100 Initiate / 101-300 Adept / 301-600 Knight / 601-1000 Lord / 1001-1500 Master, no suffix outside every band) read directly off the decomp's inclusive range checks @0x004b4ab9-@0x004b4b92 and match the ground-truth table exactly. Color rule: green when the LOCAL player shares the target's selected bit (checked first, so extra local bits don't override a match), red when local has a different bit but not the target's, normal when local has no society bits; the unrecognized "???" branch never gets a color (retail's ebx_3 stays at its zero initializer). The local player's own Faction1Bits comes from a new pure `localFactionBits` parameter on BuildExtra — the composer never reads state directly. Monarch/Patron/Followers cascade (gap G7, @0x004b4d97-@0x004b4f54): gated by the caller on AllegianceRank (Int 30) >= 1, the SAME InqInt read AS2's header AllegianceName binding already consumes. Four arms in retail's exact order: MonarchsTitle (Str 21) absent -> "Alleg. Monarch:" + clamped "%d Follower"/"%d Followers" (Int 35, singular only at exactly 1); present + PatronsTitle (Str 35) absent -> "Monarch:" only; both present and ordinally equal -> one "Monarch/Patron:" row; both present and different -> "Monarch:" then "Patron:". Configurable extras (gap G8, @0x004b58be-@0x004b5c4d): Fellowship (Str 10), Arrived in Dereth (Str 43 DateOfBirth), Time in Dereth (Int 125 Age via the ALREADY-PORTED RetailDurationText.Format — the same ClientUISystem::DeltaTimeToString @0x00565E10 the decomp calls at @0x004b59e0, reused rather than re-ported), Chess Rank (Int 181), Fishing Skill (Int 192), Deaths (Int 43 NumDeaths, <= 0 -> "Has never died" with the SAME "Deaths:" label, verified in the decomp), Titles Earned (Int 262). Each row gates independently on its own property being PRESENT (server already strips these per the target's visibility options per ground truth §3) — no client-side option or success gating added. No spacers separate these seven rows, matching the decomp exactly. Seam: AppraisalUiController takes a new `Func _localFactionBits` dependency (per-call, never captured once — the secure-trade deferred-Func lesson), invoked only on the character path. AppraisalRuntimeBindings gained `LocalFactionBits`, wired in InteractionRetainedUiComposition from `d.Character.LocalPlayer.Properties.GetInt(281)` — the SAME LocalPlayerState instance CharacterSheetProvider already reads from, no new state path. AS3 NIT 11: CharacterResponse_CombatRefreshRetainsArmorLevelRows now applies a SECOND response with different armor-level values (proving the refresh re-renders from fresh data) then a THIRD with armorLevels: null (proving it clears). AS3 NIT 12: BuildExtra's XML doc now documents the full authored row order with the @0x004b5d7d legend anchor. The test Parsed() helper now ORs IdentifyResponseFlags.ArmorLevels into Flags whenever armorLevels is supplied (realism sub-nit), applying uniformly to every existing armor-level test in the file. Ground-truth doc imprecisions found while verifying against the decomp directly: (1) the Society gate is presence-of-property, not value != 0 — InqInt's return is a found/not-found bool, the summary's "!= 0" phrasing describes the common case but not the literal branch condition; (2) the Society color rule's bit-priority (same-bit match checked before the other-bits check) wasn't spelled out in the summary table, only "same/ different/none" — confirmed exact by reading all three branches (@0x004b49fd/@0x004b4a49/@0x004b4a8b). No other disagreements found. Register: docs/architecture/retail-divergence-register.md row AP-110 retires the "exhaustive character detail regions" clause from its still-lacks list with a dated 2026-08-25 narrowing note (AS2+AS3+AS4 together closed the character-path extras list); the row's other residuals (item-object preview, effective shield projection, cooldown- remaining, augmentation-cost StringInfo, creature FontInfo-list selection) are untouched. Tests: 51 new rows-level tests in CreatureAppraisalRowsTests.cs (society gate/bit-priority/band-boundaries/color-vs-local-faction, the full allegiance cascade incl. follower singular/plural/clamp, each configurable extra present/absent, "Has never died", monster-path regression, one complete ordering-pin snapshot) plus 3 new controller- level tests in AppraisalUiControllerTests.cs through the real LayoutDesc/ template binding (localFactionBits seam wired end to end, its default fallback, and the strengthened monster-path regression). Hermetic AcDream.App.Tests: 6253 passed. Full-solution hermetic run: 15,410 passed across all projects, 0 failed. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 2 +- .../InteractionRetainedUiComposition.cs | 10 +- .../UI/Layout/AppraisalUiController.cs | 29 +- .../UI/Layout/CreatureAppraisalRows.cs | 315 ++++++++++++++++- src/AcDream.App/UI/RetailUiRuntime.cs | 13 +- .../UI/Layout/AppraisalUiControllerTests.cs | 174 ++++++++- .../UI/Layout/CreatureAppraisalRowsTests.cs | 329 ++++++++++++++++++ 7 files changed, 834 insertions(+), 38 deletions(-) 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() {