diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 3ed8bdff..1c82befa 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -360,7 +360,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-105 | **PARTIAL 2026-07-13** — inherited scrollbar media/roles now come from DAT (decrement/top `0x06004C69`, increment/bottom `0x06004C6C`), and both chat backends share typed client-command routing plus one retained `ChatVM` for reply state. Retained chat still lacks complete tab/filter/unread, social availability, incoming squelch enforcement, and focus-opacity behavior. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `ChatWindowController.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; chat mount in `GameWindow.cs` | Shared log/send path, wrapping, scrollbar roles, command ownership, and outer maximize geometry work; later chat work consolidates the remaining presentation/filter state | Tabs are no-ops, squelched lines can still render, contextual social actions are absent, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `UIElement_Scrollbar::OnSetAttribute @ 0x004714D0`; `ChatInterface` methods | | ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` | | AP-108 | Paperdoll/AutoWield still omit the remaining missile/held restrictions and corrupt-mask branch of full `AutoWieldIsLegal`, dual-wield/off-hand rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **AutoWear legality retired from this row 2026-07-23:** inventory activation and paperdoll drops now apply the retail clothing-priority/location blocker lookup and exact `"You must remove your %s to wear that"` system notice. **Primary replacement retired 2026-07-14; Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, live doll, AutoWear conflict reporting, and primary weapon/incompatible shield/mismatched ammo blocker sequencing work in peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWieldIsLegal @ 0x0055ED60`; `CPlayerSystem::AutoWearIsLegal @ 0x0055EF40`; `CPlayerSystem::AutoWield @ 0x00560A60`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` | -| AP-109 | **NARROWED 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-109 | **NARROWED FURTHER 2026-08-25 (Campaign AS slice AS5) — the 17-function heritage×gender `AllegianceSystem::GetTitle @0x005B8DD0` table and `AllegianceData::GetFullName @0x005B6950` are now ported VERBATIM** (`AllegianceRankTitleTable`, `src/AcDream.App/UI/Layout/AllegianceRankTitleTable.cs` — every one of the 17 `Get*Title` functions transcribed string-for-string from the decomp, including the PE-byte-recovered data-literal indirections in the Sho/Gearknight/Tumerok tables) **and wired to BOTH windows this row named as open**: the examination window's title bar (`AppraisalUiController.BuildCharacterTitleBarName`, called from the `character` branch of `ApplyCreature` — rank/heritage/gender read LIVE off the APPRAISAL bundle, `props.GetInt(0x1E)`/`0xBC`/`0x71`, ruling R8, never `RuntimeAllegianceState`) and the character panel's NAME line (`CharacterSheetProvider.BuildSheet`, the exact `props.GetInt(0x1Eu)` read this row's own CT4 text already prescribed as the correct future port). Both call sites were independently re-verified against the decomp at this slice: `CharExamineUI::SetAppraiseInfo`'s local `AllegianceData` (BN name `var_a8`, proven by its `CAllegianceData::CAllegianceData`/`~AllegianceData` ctor/dtor pair) never shows an explicit field WRITE for `_rank`/`_hg`/`_gender` — a Binary Ninja struct-flattening artifact, not a missing read — while `gmStatManagementUI::UpdateCharacterInfo` shows the same three `CBaseQualities::InqInt(0x71/0xbc/0x1e)` calls as plain, unambiguous locals, independently confirming the property ids this row's CT4 text already named. This closes the row's stated risk (a ranked character's Name line showing plain-name-only). **NOT closed by this slice, and the reason this row survives NARROWED rather than RETIRED:** the CT4 narrowing's own `FormatXp` caveat, immediately below — the Luminance pair's number formatting remains a `.ToString("N0", CultureInfo.InvariantCulture)` approximation of retail's `XPToString`→`GetNumberFormatA` Win32 call, unverified on an exotic negative/overflow input. That caveat is now this row's ONLY open item.** **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); `src/AcDream.App/UI/Layout/AllegianceRankTitleTable.cs` (AS5, new — the 17-function title table + `GetFullName` port); `src/AcDream.App/UI/Layout/AppraisalUiController.cs` (AS5, examination window title-bar overwrite) | 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); AS5 adds hermetic golden-value conformance tests for all 17 `Get*Title` functions (`AllegianceRankTitleTableTests`) plus fixture-layout binding tests for both the examination title bar and the character panel name line — still not a connected/live gate | The title-table gap is CLOSED (was: a ranked-allegiance character's Name line showing plain name only). The row's ONLY remaining risk: an exotic negative/overflow Luminance value could format differently than retail's byte-exact `GetNumberFormatA` — `FormatXp`'s `.ToString("N0", CultureInfo.InvariantCulture)` is a documented approximation, not a byte-identical Win32 port, and this has never been observed or reproduced | `gmStatManagementUI::UpdateCharacterInfo @ 0x004F0770`; `UpdatePKStatus @ 0x004F00A0`; `UpdateExperience @ 0x004F0A70`; `UIElement_Text::ClearAllText @ 0x004F0E31`/`0x004F0E3C`; `ACCWeenieObject::IsPK @ 0x0058C8B0`; `IsPKLite @ 0x0058C8A0`; `CharExamineUI::SetAppraiseInfo @ 0x004B45F0`; `AllegianceData::GetFullName @ 0x005B6950`; `AllegianceSystem::GetTitle @ 0x005B8DD0` | | 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 | diff --git a/src/AcDream.App/UI/Layout/AllegianceRankTitleTable.cs b/src/AcDream.App/UI/Layout/AllegianceRankTitleTable.cs new file mode 100644 index 00000000..7b92545e --- /dev/null +++ b/src/AcDream.App/UI/Layout/AllegianceRankTitleTable.cs @@ -0,0 +1,454 @@ +namespace AcDream.App.UI.Layout; + +/// +/// Campaign AS slice AS5 (2026-08-25, retires register row AP-109): the +/// retail allegiance rank-title table and its name-composition wrapper. +/// +/// Dispatch — AllegianceSystem::GetTitle @0x005B8DD0. +/// Retail tests gender first (arg3: 1 = male, 2 = female — anything +/// else falls through to "no title"), then an UNSIGNED heritage-group range +/// check (arg2 - 1) <= 0xa (heritage ids 1..11; this is what +/// excludes Olthoi/OlthoiAcid, ids 12/13, from ever getting a title), then +/// switches on heritage to one of 17 Get*Title functions. The dispatch +/// was READ DIRECTLY from the decomp (not estimated) for AP-109's +/// 2026-08-25 correction: Gearknight (heritage 6) and Tumerok (heritage 7) +/// author only a MALE function, reused verbatim for the FEMALE dispatch +/// branch too; Lugian (heritage 8) authors only a FEMALE function, reused +/// for the MALE branch; heritage id 0xA (Penumbraen) aliases to the +/// Shadowbound (heritage 5) functions on BOTH gender branches — 11 +/// heritages, 17 functions, not 22. +/// +/// Per-function bounds — every one of the 17 functions. Each +/// does its OWN unsigned bounds check, identical across all 17: +/// (arg1 - 1) > 9 → return 0 (no title). So a valid rank is +/// exactly 1..10 inclusive; rank 0 (unsigned wraps to a huge value) and any +/// rank > 10 both resolve to "no title" — there is no separate +/// "too high" clamp to the rank-10 title, contrary to what a naive port +/// might assume. +/// +/// Composition — AllegianceData::GetFullName @0x005B6950. +/// title = GetTitle(this->_rank, this->_hg, this->_gender); +/// when GetTitle returns 0 (no title resolved), the output is the +/// plain name, unmodified. When it resolves, the output is +/// title + " " + name — a single ASCII space, PE-byte-recovered at +/// data_794098 (bytes 20 00 00 00), the SAME separator +/// 's class remarks already cite for this +/// exact literal. Two call sites verified independently against the +/// decomp, both confirming (0x1E) / +/// (0xBC) / +/// (0x71) as the three +/// inputs: +/// +/// CharExamineUI::SetAppraiseInfo @0x004B45F0 (examination +/// title bar, `@0x004b4c8c`-`@0x004b4cec`): reads +/// AppraisalProfile::InqInt(arg2, 0x1e, ...) for rank immediately +/// before the GetFullName call; heritage/gender were read earlier in +/// the SAME function (InqInt(arg2, 0x71, ...) / +/// InqInt(arg2, 0xbc, ...)) for the heritage-line composition. The +/// local AllegianceData object (BN name var_a8, constructed via +/// CAllegianceData::CAllegianceData(&var_a8) and destructed via +/// AllegianceData::~AllegianceData(&var_a8) — the ctor/dtor pair +/// is the only proof BN's flattened locals belong to one struct) never shows +/// an explicit field WRITE for _rank/_hg/_gender in the +/// decompile — a known Binary Ninja struct-flattening artifact +/// (feedback_bn_decomp_field_names.md): the InqInt OUTPUT +/// pointers target those stack fields directly, and BN labels the pointed-to +/// slot with a synthetic local name instead of recognizing it as a struct +/// member. The name half of the struct IS visible as a normal assignment: +/// var_a8 = ACCWeenieObject::GetObjectName(cur_weenobj, NAME_APPROPRIATE, +/// 0) — the assessed object's own live "appropriate name", same +/// resolver +/// already serves AppraisalUiController.BuildTitle's fallback. The +/// result overwrites the window's m_displayedNameText +/// UNCONDITIONALLY (only a null-widget guard, no rank gate at the call +/// site — the "plain name when rankless" behavior lives entirely inside +/// GetFullName/GetTitle). +/// gmStatManagementUI::UpdateCharacterInfo @0x004F0770 +/// (character panel name line, `@0x004f0807`-`@0x004f0895`): this one shows +/// the three CBaseQualities::InqInt reads (0x71, 0xbc, 0x1e) as +/// plain, unambiguous locals with no flattening ambiguity — direct +/// confirmation of the same three property ids, this time straight off the +/// CACQualities qualities bundle rather than an +/// AppraisalProfile. The name appended is the LOCAL PLAYER's own +/// singular name (ACCWeenieObject::GetObjectName(SmartBox::player_id, +/// NAME_SINGULAR, 0)) — this window always shows your own sheet, never +/// another player's. +/// +/// +/// +internal static class AllegianceRankTitleTable +{ + /// PropertyInt 0x1E = AllegianceRank — the value + /// GetTitle's arg1 reads, per both verified call sites + /// above. Ruling R8 (AS1 ground truth): read this LIVE off the + /// appraisal/qualities bundle, never RuntimeAllegianceState (that + /// state exists for a DIFFERENT UI, the local player's own allegiance + /// page, and has no bearing on another player's rank). + public const uint AllegianceRankPropertyId = 0x1Eu; + + /// + /// Port of AllegianceData::GetFullName @0x005B6950: prefixes + /// with the resolved rank title + a single space + /// when resolves one; returns + /// unmodified otherwise (retail's own + /// if (GetTitle(...) == 0) { *out = name; return 0; } branch). + /// + public static string ComposeFullName(int rank, int heritageGroup, int gender, string name) + { + string? title = GetTitle(rank, heritageGroup, gender); + return string.IsNullOrEmpty(title) ? name : $"{title} {name}"; + } + + /// + /// Port of AllegianceSystem::GetTitle @0x005B8DD0's dispatch. + /// Returns null where retail returns 0 (no title: unrecognized gender, + /// heritage outside 1..11, or the resolved Get*Title function + /// itself rejects the rank). + /// + public static string? GetTitle(int rank, int heritageGroup, int gender) + { + if (gender == 1) + { + if (!IsHeritageInRange(heritageGroup)) return null; + return heritageGroup switch + { + 1 => GetAluvianMaleTitle(rank), + 2 => GetGharundimMaleTitle(rank), + 3 => GetShoMaleTitle(rank), + 4 => GetViamontianMaleTitle(rank), + 5 or 0xA => GetShadowboundMaleTitle(rank), // 0xA = Penumbraen alias + 6 => GetGearknightMaleTitle(rank), + 7 => GetTumerokMaleTitle(rank), + 8 => GetLugianFemaleTitle(rank), // Lugian authors FEMALE only; reused here + 9 => GetEmpyreanMaleTitle(rank), + 0xB => GetUndeadMaleTitle(rank), + _ => null, + }; + } + + if (gender == 2) + { + if (!IsHeritageInRange(heritageGroup)) return null; + return heritageGroup switch + { + 1 => GetAluvianFemaleTitle(rank), + 2 => GetGharundimFemaleTitle(rank), + 3 => GetShoFemaleTitle(rank), + 4 => GetViamontianFemaleTitle(rank), + 5 or 0xA => GetShadowboundFemaleTitle(rank), // 0xA = Penumbraen alias + 6 => GetGearknightMaleTitle(rank), // Gearknight authors MALE only; reused here + 7 => GetTumerokMaleTitle(rank), // Tumerok authors MALE only; reused here + 8 => GetLugianFemaleTitle(rank), + 9 => GetEmpyreanFemaleTitle(rank), + 0xB => GetUndeadFemaleTitle(rank), + _ => null, + }; + } + + return null; + } + + /// Retail's unsigned (heritage - 1) <= 0xa range test + /// — heritage ids 1..11 (0xA = Penumbraen, 0xB = Undead); excludes 0, + /// negative, and Olthoi/OlthoiAcid (12/13). + private static bool IsHeritageInRange(int heritageGroup) + => unchecked((uint)(heritageGroup - 1)) <= 0xAu; + + // ── The 17 Get*Title functions, verbatim from the decomp. ────────────── + // Every function shares the identical bounds test `(rank - 1) > 9` -> + // return 0 (ported here as the switch's default arm returning null, + // since rank values outside 1..10 have no case in any of the 17 retail + // switches either). + + /// AllegianceSystem::GetAluvianMaleTitle @0x005B7BC0 + private static string? GetAluvianMaleTitle(int rank) => rank switch + { + 1 => "Yeoman", + 2 => "Baronet", + 3 => "Baron", + 4 => "Reeve", + 5 => "Thane", + 6 => "Ealdor", + 7 => "Duke", + 8 => "Aetheling", + 9 => "King", + 10 => "High King", + _ => null, + }; + + /// AllegianceSystem::GetAluvianFemaleTitle @0x005B7CD0 + private static string? GetAluvianFemaleTitle(int rank) => rank switch + { + 1 => "Yeoman", + 2 => "Baronet", + 3 => "Baroness", + 4 => "Reeve", + 5 => "Thane", + 6 => "Ealdor", + 7 => "Duchess", + 8 => "Aetheling", + 9 => "Queen", + 10 => "High Queen", + _ => null, + }; + + /// AllegianceSystem::GetGharundimMaleTitle @0x005B7DE0 + private static string? GetGharundimMaleTitle(int rank) => rank switch + { + 1 => "Sayyid", + 2 => "Shayk", + 3 => "Maulan", + 4 => "Mu'allim", + 5 => "Naquib", + 6 => "Qadi", + 7 => "Mushir", + 8 => "Amir", + 9 => "Malik", + 10 => "Sultan", + _ => null, + }; + + /// AllegianceSystem::GetGharundimFemaleTitle @0x005B7EF0 + private static string? GetGharundimFemaleTitle(int rank) => rank switch + { + 1 => "Sayyida", + 2 => "Shayka", + 3 => "Maulana", + 4 => "Mu'allima", + 5 => "Naquiba", + 6 => "Qadiya", + 7 => "Mushira", + 8 => "Amira", + 9 => "Malika", + 10 => "Sultana", + _ => null, + }; + + /// AllegianceSystem::GetShoMaleTitle @0x005B8000. Ranks 7 and 9 + /// resolve through data-literal indirections (&data_7e6ef8 / + /// &data_7e6eec) rather than inline C string literals — PE + /// byte-read as "Kou" (4b 6f 75 00) and "Ou" (4f 75 00 00) + /// respectively, matching published AC Sho lore exactly. + private static string? GetShoMaleTitle(int rank) => rank switch + { + 1 => "Jinin", + 2 => "Jo-chueh", + 3 => "Nan-chueh", + 4 => "Shi-chueh", + 5 => "Ta-chueh", + 6 => "Kun-chueh", + 7 => "Kou", + 8 => "Taikou", + 9 => "Ou", + 10 => "Koutei", + _ => null, + }; + + /// AllegianceSystem::GetShoFemaleTitle @0x005B8110. Rank 7 shares + /// the same &data_7e6ef8 ("Kou") indirection as the male + /// table; rank 9 is a plain inline literal here ("Jo-ou"), unlike the + /// male table's rank 9. + private static string? GetShoFemaleTitle(int rank) => rank switch + { + 1 => "Jinin", + 2 => "Jo-chueh", + 3 => "Nan-chueh", + 4 => "Shi-chueh", + 5 => "Ta-chueh", + 6 => "Kun-chueh", + 7 => "Kou", + 8 => "Taikou", + 9 => "Jo-ou", + 10 => "Koutei", + _ => null, + }; + + /// AllegianceSystem::GetViamontianMaleTitle @0x005B8220 + private static string? GetViamontianMaleTitle(int rank) => rank switch + { + 1 => "Squire", + 2 => "Banner", + 3 => "Baron", + 4 => "Viscount", + 5 => "Count", + 6 => "Marquis", + 7 => "Duke", + 8 => "Grand Duke", + 9 => "King", + 10 => "High King", + _ => null, + }; + + /// AllegianceSystem::GetViamontianFemaleTitle @0x005B8330 + private static string? GetViamontianFemaleTitle(int rank) => rank switch + { + 1 => "Dame", + 2 => "Banner", + 3 => "Baroness", + 4 => "Viscountess", + 5 => "Countess", + 6 => "Marquise", + 7 => "Duchess", + 8 => "Grand Duchess", + 9 => "Queen", + 10 => "High Queen", + _ => null, + }; + + /// AllegianceSystem::GetShadowboundMaleTitle @0x005B8440. Serves + /// heritage ids 5 (Shadowbound) AND 0xA (Penumbraen alias). + private static string? GetShadowboundMaleTitle(int rank) => rank switch + { + 1 => "Tenebrous", + 2 => "Shade", + 3 => "Squire", + 4 => "Knight", + 5 => "Void Knight", + 6 => "Void Lord", + 7 => "Duke", + 8 => "Archduke", + 9 => "Highborn", + 10 => "King", + _ => null, + }; + + /// AllegianceSystem::GetShadowboundFemaleTitle @0x005B8550. Serves + /// heritage ids 5 (Shadowbound) AND 0xA (Penumbraen alias). + private static string? GetShadowboundFemaleTitle(int rank) => rank switch + { + 1 => "Tenebrous", + 2 => "Shade", + 3 => "Squire", + 4 => "Knight", + 5 => "Void Knight", + 6 => "Void Lady", + 7 => "Duchess", + 8 => "Archduchess", + 9 => "Highborn", + 10 => "Queen", + _ => null, + }; + + /// AllegianceSystem::GetGearknightMaleTitle @0x005B8660. Gearknight + /// authors ONLY this function — dispatches BOTH + /// gender branches for heritage 6 here. Rank 8 resolves through a + /// data-literal indirection (&data_7e7034), PE byte-read as + /// "Dux" (44 75 78 00). + private static string? GetGearknightMaleTitle(int rank) => rank switch + { + 1 => "Tribunus", + 2 => "Praefectus", + 3 => "Optio", + 4 => "Centurion", + 5 => "Principes", + 6 => "Legatus", + 7 => "Consul", + 8 => "Dux", + 9 => "Secondus", + 10 => "Primus", + _ => null, + }; + + /// AllegianceSystem::GetTumerokMaleTitle @0x005B8770. Tumerok + /// authors ONLY this function — dispatches BOTH + /// gender branches for heritage 7 here. Ranks 3/6/10 resolve through + /// data-literal indirections, PE byte-read as "Ona" (4f 6e 61 00), + /// "Rea" (52 65 61 00), and "Tah" (54 61 68 00) + /// respectively. + private static string? GetTumerokMaleTitle(int rank) => rank switch + { + 1 => "Xutua", + 2 => "Tuona", + 3 => "Ona", + 4 => "Nuona", + 5 => "Turea", + 6 => "Rea", + 7 => "Nurea", + 8 => "Kauh", + 9 => "Sutah", + 10 => "Tah", + _ => null, + }; + + /// AllegianceSystem::GetLugianFemaleTitle @0x005B8880. Lugian + /// authors ONLY this function — dispatches BOTH + /// gender branches for heritage 8 here. + private static string? GetLugianFemaleTitle(int rank) => rank switch + { + 1 => "Laigus", + 2 => "Raigus", + 3 => "Amploth", + 4 => "Arintoth", + 5 => "Obeloth", + 6 => "Lithos", + 7 => "Kantos", + 8 => "Gigas", + 9 => "Extas", + 10 => "Tiatus", + _ => null, + }; + + /// AllegianceSystem::GetEmpyreanMaleTitle @0x005B8990 + private static string? GetEmpyreanMaleTitle(int rank) => rank switch + { + 1 => "Ensign", + 2 => "Corporal", + 3 => "Lieutenant", + 4 => "Commander", + 5 => "Captain", + 6 => "Commodore", + 7 => "Admiral", + 8 => "Warlord", + 9 => "Ipharsin", + 10 => "Aulin", + _ => null, + }; + + /// AllegianceSystem::GetEmpyreanFemaleTitle @0x005B8AA0 + private static string? GetEmpyreanFemaleTitle(int rank) => rank switch + { + 1 => "Ensign", + 2 => "Corporal", + 3 => "Lieutenant", + 4 => "Commander", + 5 => "Captain", + 6 => "Commodore", + 7 => "Admiral", + 8 => "Warlord", + 9 => "Ipharsia", + 10 => "Aulia", + _ => null, + }; + + /// AllegianceSystem::GetUndeadMaleTitle @0x005B8BB0 + private static string? GetUndeadMaleTitle(int rank) => rank switch + { + 1 => "Neophyte", + 2 => "Acolyte", + 3 => "Adept", + 4 => "Esquire", + 5 => "Squire", + 6 => "Knight", + 7 => "Count", + 8 => "Viscount", + 9 => "Highness", + 10 => "Annointed", + _ => null, + }; + + /// AllegianceSystem::GetUndeadFemaleTitle @0x005B8CC0 + private static string? GetUndeadFemaleTitle(int rank) => rank switch + { + 1 => "Neophyte", + 2 => "Acolyte", + 3 => "Adept", + 4 => "Esquire", + 5 => "Squire", + 6 => "Knight", + 7 => "Countess", + 8 => "Viscountess", + 9 => "Highness", + 10 => "Annointed", + _ => null, + }; +} diff --git a/src/AcDream.App/UI/Layout/AppraisalUiController.cs b/src/AcDream.App/UI/Layout/AppraisalUiController.cs index 0cf1b56f..05d6f9e2 100644 --- a/src/AcDream.App/UI/Layout/AppraisalUiController.cs +++ b/src/AcDream.App/UI/Layout/AppraisalUiController.cs @@ -725,6 +725,7 @@ public sealed class AppraisalUiController : IRetainedPanelController SetText(0x10000151u, BuildCharacterTitleDisplay(p)); SetText(0x10000152u, BuildPlayerKillerDisplay(obj)); SetText(0x1000053Au, BuildAllegianceDisplay(p)); + _titleValue = BuildCharacterTitleBarName(obj, p); } else { @@ -825,6 +826,36 @@ public sealed class AppraisalUiController : IRetainedPanelController private static string BuildAllegianceDisplay(PropertyBundle p) => GetInt(p, 30u) >= 1 ? GetString(p, 47u) : string.Empty; + /// + /// Campaign AS slice AS5 (retires AP-109's title-bar residual): the char + /// path's title-bar OVERWRITE, AllegianceData::GetFullName + /// @0x005B6950 called from CharExamineUI::SetAppraiseInfo + /// (`@0x004b4c8c`-`@0x004b4cec`, AFTER 's String + /// 52/stack-count composition already ran in ). + /// Unconditional at the call site — retail's null check there guards only + /// the destination WIDGET, not the rank; the "plain name when rankless" + /// behavior lives inside GetFullName/GetTitle themselves. + /// Rank/heritage/gender read straight off the APPRAISAL bundle (ruling + /// R8: PropertyInt + /// (0x1E), never RuntimeAllegianceState, which carries a DIFFERENT + /// UI's own rank). The appended name reuses + /// — the + /// SAME "appropriate name" resolver retail's + /// ACCWeenieObject::GetObjectName(cur_weenobj, NAME_APPROPRIATE, 0) + /// call resolves at that exact call site; String 52 (ruling R1, absent + /// for players) is deliberately bypassed here exactly as retail bypasses + /// it — the overwrite recomputes the name independently of + /// 's own String-52-then-fallback chain. + /// + private string BuildCharacterTitleBarName(ClientObject obj, PropertyBundle p) + { + int rank = GetInt(p, AllegianceRankTitleTable.AllegianceRankPropertyId); + int heritageGroup = GetInt(p, CharacterIdentityText.HeritageGroupPropertyId); + int gender = GetInt(p, CharacterIdentityText.GenderPropertyId); + string name = _itemNames.ResolveAppropriateName(obj); + return AllegianceRankTitleTable.ComposeFullName(rank, heritageGroup, gender, name); + } + /// /// Rebuilds both authored appraisal lists from a freshly parsed response. /// selects between the CharExamineUI diff --git a/src/AcDream.App/UI/Layout/CharacterIdentityText.cs b/src/AcDream.App/UI/Layout/CharacterIdentityText.cs index d1339159..1632d933 100644 --- a/src/AcDream.App/UI/Layout/CharacterIdentityText.cs +++ b/src/AcDream.App/UI/Layout/CharacterIdentityText.cs @@ -12,8 +12,8 @@ namespace AcDream.App.UI.Layout; /// matched it. /// /// -/// Name-line ruling (CT4, 2026-08-24; corrected at the CT4 fix round, -/// 2026-08-25 — AP-109). Retail's NAME line +/// Name-line ruling (CT4, 2026-08-24; CLOSED at Campaign AS slice AS5, +/// 2026-08-25 — retires AP-109). Retail's NAME line /// (AllegianceData::GetFullName @0x005b6950) prefixes an allegiance /// RANK title ("<RankTitle> <Name>", same space separator, PE-read /// @data_794098) when AllegianceSystem::GetTitle(rank, heritage, gender) @@ -24,26 +24,18 @@ namespace AcDream.App.UI.Layout; /// class carries a numerically-equivalent rank for a DIFFERENT UI /// (SocialAllegiancePageController, which has no qualities-bundle /// access of its own); -/// already reads every other header property straight off -/// props.GetInt(...), so a future port reads -/// props.GetInt(0x1Eu) directly instead. The STRING half is missing: -/// GetTitle's own dispatch switch (read directly, not estimated) has -/// exactly 17 Get*Title functions, not 22 — Gearknight/Tumerok author -/// only a MALE function (reused for both genders' dispatch branches) and -/// Lugian only a FEMALE one (likewise reused both ways), so 11 heritages -/// produce 17 functions; Olthoi/OlthoiAcid have none at all (the dispatch's -/// own unsigned range check excludes heritage ids 12/13). Each function is a -/// rank-indexed switch over ~10 HARDCODED literal strings (Aluvian male: -/// "Yeoman"/"Baronet"/"Baron"/"Reeve"/"Thane"/"Ealdor"/"Duke"/"Aetheling"/ -/// "King"/"High King" — verbatim from the decomp, not DAT-resolved, not -/// guessed) — roughly 170 title strings total. That is not "reasonable -/// size" for one slice on top of its other work, so -/// 's Name label ships the -/// PLAIN-NAME case only (matching the owner's own retail screenshot, a -/// rankless character, and every current test character). The missing -/// rank-prefix path is registered -/// (docs/architecture/retail-divergence-register.md, AP-109) rather -/// than silently omitted. +/// reads every other header property straight off +/// props.GetInt(...), and now the rank-prefix too. The 17-function +/// Get*Title table (Gearknight/Tumerok author only a MALE function, +/// reused for both genders' dispatch branches; Lugian only a FEMALE one, +/// likewise reused both ways; Olthoi/OlthoiAcid excluded by the dispatch's +/// own unsigned range check) is ported verbatim at +/// , whose class remarks carry the +/// full per-function address citations and the two independently-verified +/// decomp call sites. 's Name +/// label now composes through +/// via +/// . /// internal static class CharacterIdentityText { diff --git a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs index 6191d22a..bec44426 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs @@ -164,7 +164,20 @@ public sealed class CharacterSheetProvider return new CharacterSheet { - Name = CharacterName(), + // Campaign AS slice AS5 (retires AP-109): retail's NAME line + // (gmStatManagementUI::UpdateCharacterInfo @0x004F0770, + // @0x004f0807-@0x004f0895) prefixes AllegianceData::GetFullName + // @0x005B6950's rank title exactly like the examination window's + // title bar (AppraisalUiController.BuildCharacterTitleBarName) — + // same GetFullName port, same "plain name when rankless" + // fallback. Rank/heritage/gender come straight off THIS bundle + // (props.GetInt), per AP-109's own correction — never + // RuntimeAllegianceState. + Name = AllegianceRankTitleTable.ComposeFullName( + props.GetInt(AllegianceRankTitleTable.AllegianceRankPropertyId), + props.GetInt(CharacterIdentityText.HeritageGroupPropertyId), + props.GetInt(CharacterIdentityText.GenderPropertyId), + CharacterName()), Level = displayLevel, Gender = CharacterIdentityText.GenderDisplayName( props.GetInt(CharacterIdentityText.GenderPropertyId)), diff --git a/tests/AcDream.App.Tests/UI/Layout/AllegianceRankTitleTableTests.cs b/tests/AcDream.App.Tests/UI/Layout/AllegianceRankTitleTableTests.cs new file mode 100644 index 00000000..7b546d4e --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/AllegianceRankTitleTableTests.cs @@ -0,0 +1,248 @@ +using AcDream.App.UI.Layout; +using Xunit; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Campaign AS slice AS5 conformance tests for +/// — golden values transcribed directly from AllegianceSystem::GetTitle +/// @0x005B8DD0's dispatch and its 17 Get*Title functions (see the +/// class remarks there for full address citations). Gender: 1 = male, +/// 2 = female. Heritage ids per : +/// 1 Aluvian, 2 Gharu'ndim, 3 Sho, 4 Viamontian, 5 Shadowbound/Umbraen, +/// 6 Gearknight, 7 Tumerok, 8 Lugian, 9 Empyrean, 10 (0xA) Penumbraen +/// (Shadowbound alias), 11 (0xB) Undead, 12/13 Olthoi/OlthoiAcid (excluded). +/// +public sealed class AllegianceRankTitleTableTests +{ + // ── Per-function spot checks: first / a middle / last rank each. ─────── + + [Theory] + [InlineData(1, "Yeoman")] + [InlineData(5, "Thane")] + [InlineData(10, "High King")] + public void AluvianMale_MatchesDecomp(int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 1, gender: 1)); + + [Theory] + [InlineData(1, "Yeoman")] + [InlineData(3, "Baroness")] // diverges from male's "Baron" at rank 3 + [InlineData(10, "High Queen")] + public void AluvianFemale_MatchesDecomp(int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 1, gender: 2)); + + [Theory] + [InlineData(1, "Sayyid")] + [InlineData(5, "Naquib")] + [InlineData(10, "Sultan")] + public void GharundimMale_MatchesDecomp(int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 2, gender: 1)); + + [Theory] + [InlineData(1, "Sayyida")] + [InlineData(5, "Naquiba")] + [InlineData(10, "Sultana")] + public void GharundimFemale_MatchesDecomp(int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 2, gender: 2)); + + /// Ranks 7 and 9 resolve through PE-byte-recovered data-literal + /// indirections in the decomp ("Kou", "Ou") — spot-checked explicitly, + /// not just the first/last ranks. + [Theory] + [InlineData(1, "Jinin")] + [InlineData(5, "Ta-chueh")] + [InlineData(7, "Kou")] + [InlineData(9, "Ou")] + [InlineData(10, "Koutei")] + public void ShoMale_MatchesDecomp(int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 3, gender: 1)); + + /// Rank 9 diverges from the male table ("Jo-ou" vs "Ou"); rank 7 + /// shares the male table's "Kou" indirection. + [Theory] + [InlineData(1, "Jinin")] + [InlineData(7, "Kou")] + [InlineData(9, "Jo-ou")] + [InlineData(10, "Koutei")] + public void ShoFemale_MatchesDecomp(int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 3, gender: 2)); + + [Theory] + [InlineData(1, "Squire")] + [InlineData(5, "Count")] + [InlineData(10, "High King")] + public void ViamontianMale_MatchesDecomp(int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 4, gender: 1)); + + [Theory] + [InlineData(1, "Dame")] + [InlineData(5, "Countess")] + [InlineData(10, "High Queen")] + public void ViamontianFemale_MatchesDecomp(int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 4, gender: 2)); + + [Theory] + [InlineData(1, "Tenebrous")] + [InlineData(5, "Void Knight")] + [InlineData(10, "King")] + public void ShadowboundMale_MatchesDecomp(int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 5, gender: 1)); + + [Theory] + [InlineData(1, "Tenebrous")] + [InlineData(6, "Void Lady")] // diverges from male's "Void Lord" at rank 6 + [InlineData(10, "Queen")] + public void ShadowboundFemale_MatchesDecomp(int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 5, gender: 2)); + + /// Heritage id 0xA (Penumbraen) ALIASES to the Shadowbound + /// functions on both gender dispatch branches — identical results to + /// heritage 5. + [Theory] + [InlineData(1, 1, "Tenebrous")] + [InlineData(2, 1, "Tenebrous")] + [InlineData(1, 6, "Void Lord")] + [InlineData(2, 6, "Void Lady")] + public void Penumbraen_AliasesShadowbound(int gender, int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 0xA, gender: gender)); + + [Fact] + public void Penumbraen_MatchesShadowboundExactlyAcrossAllRanksAndGenders() + { + for (int gender = 1; gender <= 2; gender++) + for (int rank = 1; rank <= 10; rank++) + { + Assert.Equal( + AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 5, gender), + AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 0xA, gender)); + } + } + + /// Gearknight authors only a MALE Get*Title function; + /// dispatches BOTH gender + /// branches to it for heritage 6. + [Theory] + [InlineData(1, "Tribunus")] + [InlineData(5, "Principes")] + [InlineData(8, "Dux")] // PE-byte-recovered data-literal indirection + [InlineData(10, "Primus")] + public void Gearknight_MaleFunctionReusedForBothGenders(int rank, string expected) + { + Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 6, gender: 1)); + Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 6, gender: 2)); + } + + /// Tumerok authors only a MALE Get*Title function; + /// dispatched for both genders on heritage 7. + [Theory] + [InlineData(1, "Xutua")] + [InlineData(3, "Ona")] // PE-byte-recovered data-literal indirection + [InlineData(6, "Rea")] // PE-byte-recovered data-literal indirection + [InlineData(10, "Tah")] // PE-byte-recovered data-literal indirection + public void Tumerok_MaleFunctionReusedForBothGenders(int rank, string expected) + { + Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 7, gender: 1)); + Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 7, gender: 2)); + } + + /// Lugian authors only a FEMALE Get*Title function; + /// dispatched for both genders on heritage 8. + [Theory] + [InlineData(1, "Laigus")] + [InlineData(5, "Obeloth")] + [InlineData(10, "Tiatus")] + public void Lugian_FemaleFunctionReusedForBothGenders(int rank, string expected) + { + Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 8, gender: 1)); + Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 8, gender: 2)); + } + + [Theory] + [InlineData(1, "Ensign")] + [InlineData(5, "Captain")] + [InlineData(10, "Aulin")] + public void EmpyreanMale_MatchesDecomp(int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 9, gender: 1)); + + [Theory] + [InlineData(1, "Ensign")] + [InlineData(9, "Ipharsia")] // diverges from male's "Ipharsin" + [InlineData(10, "Aulia")] + public void EmpyreanFemale_MatchesDecomp(int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 9, gender: 2)); + + [Theory] + [InlineData(1, "Neophyte")] + [InlineData(7, "Count")] + [InlineData(8, "Viscount")] + [InlineData(10, "Annointed")] + public void UndeadMale_MatchesDecomp(int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 0xB, gender: 1)); + + [Theory] + [InlineData(1, "Neophyte")] + [InlineData(7, "Countess")] // diverges from male's "Count" + [InlineData(8, "Viscountess")] // diverges from male's "Viscount" + [InlineData(10, "Annointed")] + public void UndeadFemale_MatchesDecomp(int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 0xB, gender: 2)); + + // ── Dispatch bounds and exclusions. ───────────────────────────────────── + + [Theory] + [InlineData(12)] // Olthoi + [InlineData(13)] // OlthoiAcid + [InlineData(0)] + [InlineData(-1)] + public void Heritage_OutsideRange_ReturnsNullForBothGenders(int heritageGroup) + { + Assert.Null(AllegianceRankTitleTable.GetTitle(rank: 1, heritageGroup, gender: 1)); + Assert.Null(AllegianceRankTitleTable.GetTitle(rank: 1, heritageGroup, gender: 2)); + } + + [Theory] + [InlineData(0)] + [InlineData(3)] + [InlineData(-1)] + public void Gender_Unrecognized_ReturnsNull(int gender) + => Assert.Null(AllegianceRankTitleTable.GetTitle(rank: 1, heritageGroup: 1, gender)); + + /// Every one of the 17 functions shares retail's identical + /// unsigned bounds test (rank - 1) > 9 — rank 0 (wraps huge) and + /// any rank > 10 both resolve to "no title", uniformly. + [Theory] + [InlineData(0)] + [InlineData(11)] + [InlineData(-1)] + [InlineData(int.MaxValue)] + public void Rank_OutsideOneToTen_ReturnsNull(int rank) + => Assert.Null(AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 1, gender: 1)); + + // ── AllegianceData::GetFullName composition. ──────────────────────────── + + [Fact] + public void ComposeFullName_ValidRank_PrefixesTitleWithSingleSpace() + { + string result = AllegianceRankTitleTable.ComposeFullName( + rank: 3, heritageGroup: 1, gender: 2, name: "Aluvia"); + Assert.Equal("Baroness Aluvia", result); + } + + [Theory] + [InlineData(0)] // rank absent / zero + [InlineData(11)] // rank out of range + public void ComposeFullName_NoTitleResolved_ReturnsPlainNameUnmodified(int rank) + { + string result = AllegianceRankTitleTable.ComposeFullName( + rank, heritageGroup: 1, gender: 1, name: "Somebody"); + Assert.Equal("Somebody", result); + } + + [Fact] + public void ComposeFullName_UnrecognizedHeritage_ReturnsPlainName() + { + string result = AllegianceRankTitleTable.ComposeFullName( + rank: 5, heritageGroup: 12 /* Olthoi */, gender: 1, name: "Xarabydun"); + Assert.Equal("Xarabydun", result); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs index 7eb2f7a2..d70c862a 100644 --- a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs @@ -459,6 +459,115 @@ public sealed class AppraisalUiControllerTests Assert.Equal("The Empire", HeaderText(layout, 0x1000053Au)); } + // ── Campaign AS slice AS5: title-bar allegiance rank-title prefix ───── + // Ground truth: docs/research/2026-08-25-campaign-as-ground-truth.md + // §2a's title-bar row, gap G9, ruling R8. Retires register row AP-109. + + [Fact] + public void CharacterResponse_TitleBarPrefixesAllegianceRankTitle() + { + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Dww", + Type = ItemType.Creature, + }); + using var interaction = NewInteraction(objects, []); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + new CombatState(), + [], + [], + () => { }, + () => { })!; + + interaction.ExamineSelectedOrEnterMode(ObjectId); + var properties = new PropertyBundle(); + properties.Strings[5u] = "Template"; // Character-view marker + properties.Ints[0x1Eu] = 3; // AllegianceRank + properties.Ints[0xBCu] = 1; // HeritageGroup: Aluvian + properties.Ints[0x71u] = 2; // Gender: Female + + Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile()))); + Assert.Equal(AppraisalView.Character, controller.ActiveView); + // Aluvian female rank 3 = "Baroness" (AllegianceRankTitleTableTests + // pins the table itself); GetFullName's single-space separator. + Assert.Equal("Baroness Dww", HeaderText(layout, AppraisalUiController.TitleId)); + } + + [Fact] + public void CharacterResponse_TitleBarPlainNameWhenRankAbsent() + { + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Dww", + Type = ItemType.Creature, + }); + using var interaction = NewInteraction(objects, []); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + new CombatState(), + [], + [], + () => { }, + () => { })!; + + interaction.ExamineSelectedOrEnterMode(ObjectId); + var properties = new PropertyBundle(); + properties.Strings[5u] = "Template"; // Character-view marker, no AllegianceRank. + + Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile()))); + Assert.Equal(AppraisalView.Character, controller.ActiveView); + Assert.Equal("Dww", HeaderText(layout, AppraisalUiController.TitleId)); + } + + /// Regression pin: CreatureExamineUI (monsters) never + /// calls AllegianceData::GetFullName — the overwrite is scoped to + /// the character branch of ApplyCreature only, even when + /// rank/heritage/gender-shaped properties happen to be present. + [Fact] + public void CreatureResponse_TitleBarNeverGetsAllegianceRankPrefix() + { + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Drudge", + Type = ItemType.Creature, + }); + using var interaction = NewInteraction(objects, []); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + new CombatState(), + [], + [], + () => { }, + () => { })!; + + interaction.ExamineSelectedOrEnterMode(ObjectId); + var properties = new PropertyBundle(); + // No String 5 / Int 0x105 -> monster path, NOT the character view. + properties.Ints[0x1Eu] = 3; + properties.Ints[0xBCu] = 1; + properties.Ints[0x71u] = 2; + + Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile()))); + Assert.Equal(AppraisalView.Creature, controller.ActiveView); + Assert.Equal("Drudge", HeaderText(layout, AppraisalUiController.TitleId)); + } + [Fact] public void CharacterResponse_HeritageFallsBackToCreatureTypeWhenGroupIsZero() { diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs index 623f2da6..efef3164 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs @@ -104,6 +104,36 @@ public sealed class CharacterSheetProviderTests Assert.Equal(90L, sheet.AttributeRaise10Costs[0]); } + // ── Campaign AS slice AS5: name-line allegiance rank-title prefix ────── + // Retires register row AP-109. Same GetFullName port + rank-property id + // (props.GetInt(0x1E)) as the examination window's title bar + // (AppraisalUiControllerTests.CharacterResponse_TitleBarPrefixes*). + + [Fact] + public void BuildSheet_WithAllegianceRank_PrefixesNameWithRankTitle() + { + var h = new Harness(); + var player = h.AddPlayerObject(); + player.Properties.Ints[0x1Eu] = 3; // AllegianceRank + player.Properties.Ints[0xBCu] = 1; // HeritageGroup: Aluvian + player.Properties.Ints[0x71u] = 2; // Gender: Female + + var sheet = h.Provider.BuildSheet(); + + Assert.Equal("Baroness Testy", sheet.Name); + } + + [Fact] + public void BuildSheet_WithoutAllegianceRank_NameStaysPlain() + { + var h = new Harness(); + h.AddPlayerObject(); + + var sheet = h.Provider.BuildSheet(); + + Assert.Equal("Testy", sheet.Name); + } + [Fact] public void BuildSheet_AfterLiveInt64Updates_RefreshesBothXpWindowsAndMeter() {