# Retail Fellowship & Allegiance panels — class families, mount, element inventory, dialogs, open paths **Date:** 2026-08-11 **Lane:** Campaign FA **RESEARCH LANE A** — retail panel STRUCTURE. **Status:** RESEARCH ONLY — no production code changed, nothing built, nothing launched, no client attached, no DATs read. **Quality bar / format model:** `docs/research/2026-08-10-options-panel-structure.md` (especially its §10.1 structural-inventory style). Where that doc is the authority for a shared mechanism (the `0x2100006E` floaty host, `P0x10000029` slot keys, the `AddItemFromTemplateList` row mechanism, the `UIOption_Checkbox` row shape) this doc cites it rather than re-deriving it. --- ## 0. Primary sources and evidence discipline | Source | Use | |---|---| | `docs/research/named-retail/acclient_2013_pseudo_c.txt` | Binary Ninja pseudo-C, Sept 2013 EoR build, PDB names. Cited as `pseudo_c:`. | | `docs/research/named-retail/acclient.h` | **Verbatim retail header struct definitions** — the member lists in §1.2 are copied from it, not inferred. | | `docs/research/named-retail/symbols.json` | Address ↔ name, cited as VAs. | | **Ghidra MCP on `127.0.0.1:8081`** (`patchmem.gpr`, same 2013 build + full PDB) | `GET /decompile_function?address=0x…`. **Ghidra resolves the named string-id globals and element-id constants that Binary Ninja folded to `0`.** Every `SetStringIDandTableEnum(&si, 0, 0x10000001)` in the BN text below was re-read here and came back with a real `ID_*` symbol. Cited as `ghidra@0x…`. | | `docs/research/named-retail/retail-default.keymap.txt` / `keymap-default.txt` | The two open actions and their default keys. | | acdream tree (`src/AcDream.App/UI/**`, `tests/.../fixtures/*.json`) | Seam comparison + the collision sweep in §6.3. | **Not used, and therefore an explicit limit on this doc:** the installed DATs. Every authored fact (LayoutDesc DIDs, geometry, media, base references, template arrays, the `P0x10000029` slot keys, the static labels code never touches) is listed in **§8 Unknowns for the coordinator** with an exact verification recipe. Nothing in this doc is guessed; where the decomp only *strongly indicates* something, it says so. ### 0.1 BN zero-fold ledger (per the brief's warning) Binary Ninja rendered **21 distinct** `StringInfo::SetStringIDandTableEnum(&si, 0, )` call sites across the two classes with a literal `0` first operand. All 21 are **resolved, not asserted** — Ghidra's decompiler of the same binary names the global at each site (§4.1/§4.2 tables). Additionally: * The `ID_*` globals themselves are `= 0x0` in the pseudo-C data dump (`pseudo_c:1147705-1147793`). That is **not** a fold — they are genuinely zero at link time and filled at startup by `ID_Fellowship_FellowName = compute_str_hash("ID_Fellowship_FellowName")` (`pseudo_c:767586-767956`). See §4.3 — this changes how acdream must resolve them. * Two BN `neg/sbb` zero-idioms appear in `gmFellowshipUI::UpdateButtons` (`((eax - eax) & 0xc) + 1` and `((ebx - 1) & 0xfffffff4) + 0xd`). Both are decoded in §3.4; Ghidra renders the first honestly as `(-(iVar4 != 0) & 0xcU) + 1` (`ghidra@0x0048E6C0:97`), which corroborates the decode. --- ## 1. The class family (Q1) ### 1.1 Headline **There is no `gmSocialUI`, and Fellowship and Allegiance are not tabs of one panel.** They are two independent `UIElement` subclasses, each registered under its own element class id, each authored in its own LayoutDesc, each mounted as its own sibling in the shared `gmPanelUI` page stack — exactly the Character-Info / Options pattern. | Retail class | Element class id | `Register` | `Create` | `PostInit` | Base classes (`acclient.h`) | |---|---|---|---|---|---| | `gmAllegianceUI` | **`0x1000002C`** | `0x004911F0` | `0x00490D10` | `0x00490E90` | `UIElement_Field`, `gmNoticeHandler`, `QualityChangeHandler` | | `gmFellowshipUI` | **`0x1000002D`** | `0x0048E6A0` | `0x0048E400` | `0x0048FD40` | `UIElement_Field`, `gmNoticeHandler` | * `UIElement::RegisterElementClass(0x1000002c, gmAllegianceUI::Create)` — `pseudo_c:157423` * `UIElement::RegisterElementClass(0x1000002d, gmFellowshipUI::Create)` — `pseudo_c:154913` * Both are registered from the same startup block as every other panel class (`pseudo_c:135145-135146`). * Struct definitions: `acclient.h:56038` (`gmAllegianceUI`), `acclient.h:56065` (`gmFellowshipUI`). **Neighbourhood in the class-id space** (full `RegisterElementClass` sweep of the binary): `0x1000002A gmAttributeUI`, `0x1000002B gmSkillUI`, **`0x1000002C gmAllegianceUI`**, **`0x1000002D gmFellowshipUI`**, `0x1000002E gmSpellbookUI`, `0x1000002F gmSpellComponentUI`. The other social-ish surfaces are also separate classes and separate windows — they are *not* siblings inside a social panel: `0x10000045 gmFriendsUI`, `0x10000046 gmCharacterTitleUI`, `0x10000047 gmSquelchUI`, `0x10000048 gmJournalUI`, `0x10000049 gmPageListUI`. Their struct definitions sit immediately after Fellowship's in the header (`acclient.h:56085` `gmFriendsUI`, `:56098` `gmSquelchUI`), which is why a "social host" is easy to hypothesise and wrong. ### 1.2 Member fields — verbatim from `acclient.h` These member lists **are** the element inventory: every `UIElement*` member is one authored element the panel binds in `PostInit`. `gmAllegianceUI` (`acclient.h:56038-56061`): ``` bool m_bAwaitingUpdate; unsigned int m_iidSelectedVassal; unsigned int m_iidPossibleNewPatron; unsigned int m_iidPossibleKickedVassal; unsigned int m_uiAcceptSwearServerContextID; UIElement_Text *m_pAllegianceName; UIElement_Text *m_pPlayerFollowers; UIElement_Text *m_pPlayerRank; UIElement *m_pMonarchField; UIElement_Text *m_pMonarchLabel; UIElement_Text *m_pMonarchName; UIElement_Text *m_pMonarchFollowers; UIElement *m_pPatronField; UIElement_Text *m_pPatronName; UIElement_ListBox *m_pVassalListBox; UIElement_Button *m_pSwearButton; UIElement_Button *m_pBreakButton; UIElement_Button *m_pKickButton; unsigned int m_swearContext; unsigned int m_acceptSwearContext; unsigned int m_breakContext; unsigned int m_kickContext; ``` `gmFellowshipUI` (`acclient.h:56065-56082`): ``` CFellowship *m_pFellowship; unsigned int m_iidSelectedFellow; unsigned int m_uiAcceptFellowRequestServerContextID; unsigned int m_fellowRequestContext; UIElement *m_pNotInAFellowshipFrame; UIElement *m_pInAFellowshipFrame; UIElement_Text *m_pFellowshipNameEntryBox; UIElement_Button *m_pCreateFellowshipButton; UIElement_Text *m_pFellowshipName; UIElement_ListBox *m_pFellowsListBox; UIElement_Button *m_pFellowLeaderButton; UIElement_Button *m_pFellowQuitButton; UIElement_Button *m_pFellowOpenButton; UIElement_Button *m_pFellowRecruitButton; UIElement_Button *m_pFellowDismissButton; UIElement_Button *m_pFellowDisbandButton; ``` Note **four** dialog-context slots on Allegiance vs **one** on Fellowship — Allegiance runs four distinct confirmation dialogs (§5), Fellowship runs one. ### 1.3 Method inventory (all VAs from `symbols.json`) | `gmFellowshipUI` | VA | `gmAllegianceUI` | VA | |---|---|---|---| | `PostInit` | `0x0048FD40` | `PostInit` | `0x00490E90` | | `OnVisibilityChanged` | `0x0048E460` | `OnVisibilityChanged` | `0x00491240` | | `ListenToElementMessage` | `0x004901C0` | `ListenToElementMessage` | `0x00493030` | | `ListenToGlobalMessage` | `0x004F5860` *(folded no-op — see §7.1)* | `ListenToGlobalMessage` | `0x004908B0` | | `Update` | `0x0048F440` | `Update` | `0x00492670` | | `UpdateButtons` | `0x0048E6C0` | `UpdateSwearButton` / `UpdateBreakButton` | `0x004908E0` / `0x004909D0` | | `UpdateFellowStats` | `0x0048EB20` | `UpdatePlayerData` | `0x00491330` | | `UpdateFellowVitals` | `0x0048ED60` | `UpdatePatronData` | `0x004917C0` | | `UpdateFellowSelection` | `0x0048F0F0` | `UpdateMonarchData` | `0x00491B40` | | `CreateFellowship` | `0x0048F730` | `UpdateVassalsData` | `0x00492340` | | `RecruitFellow` / `DismissFellow` | `0x0048E530` / `0x0048E470` | `MakeSwearConfirmationDialog` | `0x004927B0` | | `AssignLeadershipToFellow` | `0x0048F1E0` | `MakeAcceptSwearConfirmationDialog` | `0x00492990` | | `MakeFellowRequestDialog` | `0x00490620` | `MakeBreakConfirmationDialog` | `0x00492BF0` | | `RecvNotice_CloseDialog` | `0x0048F2C0` | `MakeKickConfirmationDialog` | `0x00492E10` | | `RecvNotice_AbortConfirmationRequest` | `0x0048E430` | `Close{AcceptSwear,Break,Kick}ConfirmationDialog` | `0x00490A50` / `0x00490A90` / `0x00490B00` | | `FellowshipDisbanded` / `FellowDismissed` / `FellowQuit` / `FellowAdded` / `FellowUpdated` | `0x0048F8A0` / `0x0048F970` / `0x0048FAE0` / `0x0048FC20` / `0x0048FCD0` | `RecvNotice_CloseDialog` / `_AbortConfirmationRequest` / `_AllegianceUpdate` / `_AllegianceUpdateAborted` / `_AllegianceLogin` / `_SwearAllegianceRequest` / `_PlayerDescReceived` / `_EnchantmentsChanged` / `_SelectionChanged` | `0x00492060` / `0x00490D80` / `0x00492780` / `0x00492790` / `0x00492220` / `0x00493110` / `0x00490D40` / `0x00492050` / `0x00490D70` | --- ## 2. LayoutDesc resolution and mounting (Q2) ### 2.1 How each panel's layout is resolved — **not** by a DID lookup in code Neither class calls `DBCache::GetDIDFromEnumStatic` (`0x00413910`) or any literal-DID load. Both constructors take `(LayoutDesc const*, ElementDesc const*)` (`pseudo_c:154716`, `pseudo_c:157087`) — i.e. the element factory instantiates them from an **authored** element whose `Type` property equals the registered class id. The binding is therefore **entirely data-side**: some LayoutDesc contains an element with `Type = 0x1000002D` (Fellowship) / `Type = 0x1000002C` (Allegiance). This is the same mechanism the Options tab pages use (`docs/research/2026-08-10-options-panel-structure.md` §1.2 — page roots typed `0x10000027`/`0x10000028`/`0x10000029`/`0x10000042`). **Consequence:** the two LayoutDesc DIDs cannot be recovered from the decomp at all. They are §8 item **U1**. ### 2.2 Where they mount — the shared `gmPanelUI` page stack, byte-exact `gmPanelUI::SetupChildren @ 0x004BC9E0` (`ghidra@0x004BC9E0`) is the decisive evidence. It hard-codes **16** page-stack slot element ids, and for each one reads that slot's own authored `P0x10000029` enum into a `PanelChildInfo { UIElement* child; ulong panelID; }`: ``` this_01 = UIElement::GetChildRecursive(this, 0x1000018b); UIElement::GetAttribute_Enum(this_01, 0x10000029, &local_84); … append {child, panelID} … ← repeated 16× … for each entry: entry.child->SetVisible(false); ← every panel starts hidden ``` The 16 slot ids **in authored call order**: | # | Slot element | `P0x10000029` panel id | Identified as | |---|---|---|---| | 1 | `0x1000018B` | **7** | Inventory | | 2 | `0x1000018F` | ? | — | | 3 | `0x1000018E` | **11** | Character / Skills | | 4 | `0x10000559` | ? | — | | 5 | `0x1000018C` | ? | — | | 6 | `0x10000182` | ? | — | | 7 | `0x1000018D` | **10** | Options | | 8 | `0x10000190` | **13** | Magic | | 9 | `0x10000184` | **4** | Helpful Effects | | 10 | `0x10000185` | **5** | Harmful Effects | | 11 | `0x10000181` | ? | — | | 12 | `0x10000189` | ? | — | | 13 | `0x10000183` | **3** | Character Information | | 14 | `0x1000018A` | ? | — | | 15 | `0x10000187` | ? | — | | 16 | `0x10000188` | ? | — | The identified rows come from `docs/research/2026-07-17-retail-shared-main-panel-pseudocode.md:88-96` (a prior DAT-verified dump of `0x2100006E`) plus `docs/research/2026-08-10-options-panel-structure.md` §1.4 for Options. `0x10000186` is conspicuously **absent** from `SetupChildren` even though it falls inside the contiguous run — flag, not fact. **So: Fellowship and Allegiance are two of the ten unidentified slots above** (`0x10000181`, `0x10000182`, `0x10000187`, `0x10000188`, `0x10000189`, `0x1000018A`, `0x1000018C`, `0x1000018F`, `0x10000559`). `RetailPanelCatalog` (`src/AcDream.App/UI/RetailPanelCatalog.cs:10-29`) already claims panel ids 3, 4, 5, 7, 8, 9, 10, 11, 13, 15; the unclaimed ids in that space are **1, 2, 6, 12, 14**. Two of those five are Fellowship and Allegiance. > **I deliberately did not interpolate.** The slot-id → panel-id mapping *looks* monotonic > over the identified rows (`0x183`→3, `0x184`→4, `0x185`→5, `0x18B`→7, `0x18D`→10, > `0x18E`→11, `0x190`→13), which would put Fellowship/Allegiance at `0x10000181`/`0x10000182` > (panel ids 1 and 2). But four unidentified slots (`0x187`–`0x18A`) sit between panel 5 and > panel 7, where only panel id 6 is free — so monotonicity **provably fails somewhere**, and > an interpolated answer would be a guess. §8 item **U2**. ### 2.3 One-at-a-time visibility — the behavioural contract the mount inherits `gmPanelUI::RecvNotice_SetPanelVisibility @ 0x004BC6F0` (`ghidra@0x004BC6F0`): showing panel *P* looks *P* up in `m_childrenInfoArray`, and if a *different* child is currently shown it reads **that** child's `P0x10000029`, sends `CM_UI::SendNotice_SetPanelVisibility(previousPanelId, false)`, then shows *P* and the host frame. Hiding the active child either falls back to a remembered previous child or hides the host frame entirely. acdream already ports this as `RetailPanelUiController.RegisterMainPanel` (`src/AcDream.App/UI/Layout/RetailPanelUiController.cs:47`), which is what `RetailUiRuntime.MountOptionsPanel` and `MountCharacterInformationPanel` call. ### 2.4 The acdream seam — what a Fellowship/Allegiance mount will look like Two existing mounts are the template, and they are structurally identical: | Panel | Import call | acdream source | |---|---|---| | Character Information | `LayoutImporter.ImportInfos(dats, 0x2100006E, 0x10000183)` → `LayoutImporter.Build` → `CharacterController.Bind` → `RegisterIndicatorDetailPanel(RetailPanelCatalog.CharacterInformation, …)` | `src/AcDream.App/UI/RetailUiRuntime.cs:1634-1671`; ids at `src/AcDream.App/UI/Layout/CharacterController.cs:14-15` | | Options | `LayoutImporter.ImportInfos(dats, 0x2100006E, 0x1000018D)` → `Build` → `OptionsPanelController.Bind` → `RegisterMainPanel` | `src/AcDream.App/UI/RetailUiRuntime.cs:2028-2075`; ids at `src/AcDream.App/UI/Layout/OptionsPanelController.cs:40-45` | `ImportInfos(host, slotElementId)` returns the **fully base-merged** subtree at that slot (`src/AcDream.App/UI/Layout/OptionsPanelController.cs:9-28` documents the empirical verification), so a Fellowship/Allegiance mount needs **only the slot element id** — not the panel's own LayoutDesc DID. That makes §8 item **U2** the single blocking unknown for mounting, and **U1** merely nice-to-have. Contrast: most other panels (Vitae `0x21000020/0x100001C1`, LinkStatus `0x2100001D/0x10000167`, MiniGame `0x2100001E/0x1000016A`, Spellbook `0x21000034/0x100002A8`) are imported from their **own** LayoutDesc rather than through the host. Either route is available; the Character-Info/Options route is the one that gives the retail "one active sibling" behaviour for free. --- ## 3. Element inventories (Q3) Every id below is a code-observed `GetChildRecursive` / `GetChildRecursiveTemplate` / `SetAttribute_*` operand. **Geometry (x/y/w/h), media, fonts, and base references are DAT facts and are NOT in this doc** — §8 item **U3**. The DynamicCast id column is retail's own `UIElement::DynamicCast` argument, which is the element `Type`. ### 3.1 Structural inventory — `gmFellowshipUI` (`0x1000002D`) | Element id | Cast/Type | Bound member | Role | Anchor | |---|---|---|---|---| | `0x1000026B` | *(none)* | `m_pNotInAFellowshipFrame` | **Empty-state frame** — shown when you have no fellowship | `pseudo_c:156290` (`0x0048FF88`) | | `0x1000026F` | `0xC` `UIElement_Text` | `m_pFellowshipNameEntryBox` | **Editable fellowship-name field** (inline, not a dialog) | `pseudo_c:156292,156300` (`0x0048FFA0`) | | `0x10000270` | `0x10000035` `UIOption_Checkbox` | *(local)* | Toggle → `PlayerOption 2 IgnoreFellowshipRequests` | `pseudo_c:156208-156222` (`0x0048FD60`); `ghidra@0x0048FD40:35-52` | | `0x10000271` | `0x10000035` | *(local)* | Toggle → `PlayerOption 0x12 FellowshipAutoAcceptRequests` | `ghidra@0x0048FD40:54-72` | | `0x10000272` | `0x10000035` | *(local)* | Toggle → `PlayerOption 0x0F FellowshipShareXP` | `ghidra@0x0048FD40:74-92` | | `0x10000273` | `0x10000035` | *(local)* | Toggle → `PlayerOption 0x11 FellowshipShareLoot` | `ghidra@0x0048FD40:94-116` | | `0x10000274` | `1` `UIElement_Button` | `m_pCreateFellowshipButton` | **Create Fellowship** | `pseudo_c:156301,156309` (`0x0048FFC6`) | | `0x10000275` | *(none)* | `m_pInAFellowshipFrame` | **Populated-state frame** | `pseudo_c:156291` (`0x0048FF9A`) | | `0x10000276` | `0xC` | `m_pFellowshipName` | Current fellowship's name (display) | `pseudo_c:156310,156318` (`0x0048FFEC`) | | `0x10000277`, `0x10000278` | ? | — | **Not referenced by code** — static labels/column headers. §8 **U4** | — | | `0x10000279` | `5` `UIElement_ListBox` | `m_pFellowsListBox` | **Member list** | `pseudo_c:156319,156327` (`0x00490012`) | | `0x1000027A` | ? | — | **Not referenced by code** — very likely the list's scrollbar. §8 **U4** | — | | `0x1000027B` | `1` | `m_pFellowLeaderButton` | Assign leadership to selected fellow | `pseudo_c:156328,156336` (`0x00490038`) | | `0x1000027C` | `1` | `m_pFellowQuitButton` | Quit fellowship | `pseudo_c:156345` (`0x0049007E`) | | `0x1000027D` | `1` | `m_pFellowOpenButton` | **Open / Close fellowship toggle** (label swaps at runtime, §4.1) | `pseudo_c:156354` (`0x004900A4`) | | `0x1000027E` | `1` | `m_pFellowRecruitButton` | Recruit currently-selected world object | `pseudo_c:156363` (`0x004900CA`) | | `0x1000027F` | `1` | `m_pFellowDismissButton` | Dismiss selected fellow | `pseudo_c:156372` (`0x004900F0`) | | `0x10000280` | `1` | `m_pFellowDisbandButton` | Disband fellowship | `pseudo_c:156381` (`0x00490113`) | | `0x10000281`, `0x10000282` | ? | — | **Not referenced by code**. §8 **U4** | — | **Fellow row template** — one row per `Fellow`, built by `UIElement_ListBox::AddItemFromTemplateList(m_pFellowsListBox, 0, nullptr)` (template index **0**), then decorated: | Row element | Cast/Type | Content | Anchor | |---|---|---|---| | *(row root)* | — | `SetAttribute_InstanceID(row, 0x1000000D, fellowIid)` — the row's identity key | `ghidra@0x0048F440:95` | | `0x10000283` | `0xC` | Fellow name — `ID_Fellowship_FellowName` + var `ID_Name` | `ghidra@0x0048F440:96-115` | | `0x10000284` | `0xC` | Level + XP-share % — `ID_Fellowship_FellowStats` + vars `ID_Level`, `ID_Experience` | `ghidra@0x0048EB20:51-80` | | `0x10000285` | `7` `UIElement_Meter` | **Health bar** — `SetAttribute_Float(bar, 0x69, cur/max)` | `ghidra@0x0048ED60:50-57` | | `0x10000286` | `0xC` | Health text — `ID_Fellowship_FellowHealthStatus` + `ID_Cur`, `ID_Max` (child **of the meter**) | `ghidra@0x0048ED60:59-73` | | `0x10000287` | `7` | **Stamina bar** | `ghidra@0x0048ED60:80-87` | | `0x10000288` | `0xC` | Stamina text — `ID_Fellowship_FellowStaminaStatus` | `ghidra@0x0048ED60:89-102` | | `0x10000289` | `7` | **Mana bar** | `ghidra@0x0048ED60:108-116` | | `0x1000028A` | `0xC` | Mana text — `ID_Fellowship_FellowManaStatus` | `ghidra@0x0048ED60:117-131` | The health/stamina/mana ordering is byte-exact from the `Fellow` field offsets each bar reads: `[0xA]/[7]`, `[0xB]/[8]`, `[0xC]/[9]` (cur/max), in that source order. ### 3.2 Structural inventory — `gmAllegianceUI` (`0x1000002C`) | Element id | Cast/Type | Bound member | Role | Anchor | |---|---|---|---|---| | `0x10000251` | `0xC` | `m_pAllegianceName` | Your allegiance/character line — `ID_Allegiance_CharacterName` + `ID_Name` | `pseudo_c:157301` (`0x00490F37`/`0x00490F57`) | | `0x10000252` | `0xC` | `m_pPlayerFollowers` | Your follower count — `ID_Allegiance_Followers` + `ID_Followers` | `pseudo_c:157310` (`0x00490F7D`) | | `0x10000253` | `0xC` | `m_pPlayerRank` | Your rank/title — `ID_Allegiance_Rank` or `ID_Allegiance_RankBuffed` | `pseudo_c:157319` (`0x00490FA3`) | | `0x10000254` | ? | — | Not referenced by code. §8 **U4** | — | | `0x10000255` | *(none)* | `m_pMonarchField` | **Monarch block container** — hidden when there is no monarch, or you *are* the monarch | `pseudo_c:157320` (`0x00490FB5`); `ghidra@0x00491B40:78-101` | | `0x10000256` | `0xC` | `m_pMonarchLabel` | Monarch block's caption — text swaps, §4.2 | `pseudo_c:157329` (`0x00490FDB`) | | `0x10000257` | `0xC` | `m_pMonarchName` | Monarch name (or `L" "` when absent) | `pseudo_c:157338` (`0x00491001`) | | `0x10000258` | `0xC` | `m_pMonarchFollowers` | Monarch's follower count | `pseudo_c:157347` (`0x00491027`) | | `0x10000259` | ? | — | Not referenced by code. §8 **U4** | — | | `0x1000025A` | *(none)* | `m_pPatronField` | **Patron block container** — hidden when you have no patron (or your patron == the monarch) | `pseudo_c:157348` (`0x00491039`); `ghidra@0x004917C0` | | `0x1000025B` | ? | — | Not referenced by code. §8 **U4** | — | | `0x1000025C` | `0xC` | `m_pPatronName` | Patron name (or `L" "`) | `pseudo_c:157357` (`0x0049105F`) | | `0x1000025D`–`0x1000025F` | ? | — | Not referenced by code. §8 **U4** | — | | `0x10000260` | `5` `UIElement_ListBox` | `m_pVassalListBox` | **Vassal list** | `pseudo_c:157366` (`0x00491085`) | | `0x10000261` | ? | — | Not referenced by code — very likely the list's scrollbar. §8 **U4** | — | | `0x10000262` | `0x10000035` `UIOption_Checkbox` | *(local)* | Toggle → `PlayerOption 1 IgnoreAllegianceRequests` | `pseudo_c:157274-157292` (`0x00490EB0`) | | `0x10000263` | `1` `UIElement_Button` | `m_pSwearButton` | **Swear Allegiance** to the selected world object | `pseudo_c:157375` (`0x004910AB`) | | `0x10000264` | `1` | `m_pBreakButton` | **Break Allegiance** (from your patron) | `pseudo_c:157384` (`0x004910D1`) | | `0x10000265` | `1` | `m_pKickButton` | **Break Allegiance** of the selected vassal (kick) | `pseudo_c:157393` (`0x004910F7`) | | `0x10000490` | *(none)* | *(child of `m_pMonarchField`)* | Sub-block shown **only when your patron IS the monarch** — carries the XP-passed-up line | `ghidra@0x00491B40:121-123,153-154` | | `0x10000492` | `0xC` | *(child of `0x10000490` / of `m_pPatronField`)* | "Experience passed up" text — `ID_Allegiance_VassalExperiencePassedUp` + `ID_Value`. **Looked up under BOTH `m_pMonarchField/0x10000490` and `m_pPatronField`** | `ghidra@0x00491B40:136-139`; `ghidra@0x004917C0` | **Vassal row template** — one row per **direct** vassal, built by `AddItemFromTemplateList(m_pVassalListBox, 0, nullptr)` (template index **0**): | Row element | Cast/Type | Content | Anchor | |---|---|---|---| | *(row root)* | — | `SetAttribute_InstanceID(row, 0x10000001, vassalIid)` — **note: a different attribute key from Fellowship's `0x1000000D`** | `ghidra@0x00492340` | | `0x10000268` | `0xC` | Vassal name (`AllegianceData::GetFullName`, set as literal text — **no** StringInfo template) | `ghidra@0x00492340` | | `0x10000269` | `0xC` | XP passed up — `ID_Allegiance_VassalExperiencePassedUp` + `ID_Value` | `ghidra@0x00492340` | | `0x100004AA` | `0xC` | **Offline marker** — `SetVisible(1)` when `!IsLoggedIn`, `SetVisible(0)` when logged in | `ghidra@0x00492340` | **The vassal list is FLAT, not a tree.** `UpdateVassalsData` iterates `AllegianceProfile::GetFirstVassal(profile, playerId, &data)` → `GetNextVassal(profile, prevId, &data)` — the player's **direct** vassals only. There is no recursion, no indent level, no expand/collapse. Retail's Allegiance panel shows four things: you, your monarch, your patron, and your direct vassals. ### 3.3 What is *not* in either panel * **No text-entry field on Allegiance.** Swear targets the currently-selected world object; there is no name box. * **No "open fellowship" checkbox.** The open/closed state is a **button** whose caption swaps (`0x1000027D`, §4.1) — asked in the brief; answered: it is not a checkbox. * **No Apply/Reset/Defaults on either panel.** Both fire their wire events immediately. * **No tab control on either panel.** Neither authors a `Type 8` root. * **No allegiance MOTD / officer / ban / hometown UI.** Those `CM_Allegiance` events exist (`Event_SetMotd 0x006A7E70`, `Event_SetAllegianceOfficer 0x006A7C80`, `Event_AddAllegianceBan 0x006A7520`, `Event_RecallAllegianceHometown 0x006A7100`, …) but **no `gmAllegianceUI` code path calls any of them** — they are chat-command-only in 2013. acdream already routes several of them through `ClientCommandController` (`src/AcDream.App/UI/ClientCommandController.cs:334-341`). --- ## 4. Runtime text, button state, and the empty state (Q3/Q5) ### 4.1 `gmFellowshipUI` — every string site | Site | VA | String global | Variables | |---|---|---|---| | `UpdateButtons` — `_open_fellow == 0` | `0x0048E96B` | `ID_Fellowship_OpenFellowshipButtonText` | — | | `UpdateButtons` — `_open_fellow != 0` | `0x0048E903` | `ID_Fellowship_CloseFellowshipButtonText` | — | | `Update` (row name) | `0x0048F60E` | `ID_Fellowship_FellowName` | `ID_Name` | | `UpdateFellowStats` | `0x0048ECAF` | `ID_Fellowship_FellowStats` | `ID_Level`, `ID_Experience` | | `UpdateFellowVitals` ×3 | `0x0048EEB7` / `0x0048EF9D` / `0x0048F083` | `ID_Fellowship_FellowHealthStatus` / `…FellowStaminaStatus` / `…FellowManaStatus` | `ID_Cur`, `ID_Max` | | `RecruitFellow` | `0x0048E5E3` / `0x0048E5C2` | `ID_Fellowship_Error_CantRecruitSelf` / `ID_Fellowship_Error_PlayerAlreadyInFellowship` | — | | `DismissFellow` | `0x0048E4FD` / `0x0048E4AE` | `ID_Fellowship_Error_CantDismissSelf` / `ID_Fellowship_Error_DismisseeNotInFellowship` | — | | `AssignLeadershipToFellow` | `0x0048F2xx` | `ID_Fellowship_Error_SelfAlreadyLeader` | — | | `MakeFellowRequestDialog` | `0x00490674` | `ID_Fellowship_FellowshipRequest` | `ID_Player` | Thirteen `ID_Fellowship_*` globals exist (`pseudo_c:1147705-1147717`), and all thirteen are consumed above — the family is complete. > **The Open/Close button reads as the ACTION, not the state.** `_open_fellow == 0` (a > *closed* fellowship) shows **"Open Fellowship"**. Verified in > `ghidra@0x0048E6C0:109-127`. The three error strings go to the chat scroll via `ECM_UI::SendNotice_DisplayStringInfo(0x1A, &si)` — **client-side only**, no wire traffic (`pseudo_c:154820`, `:154867`). *(The `0x1A` channel/LogTextType is not established here — cross-check against `claude-memory/project_chat_digest.md`.)* ### 4.2 `gmAllegianceUI` — every string site | Site | String global | Variables | |---|---|---| | `UpdatePlayerData` | `ID_Allegiance_CharacterName` | `ID_Name` | | `UpdatePlayerData` | `ID_Allegiance_Followers` | `ID_Followers` | | `UpdatePlayerData` (unbuffed rank) | `ID_Allegiance_Rank` | `ID_Title`, `ID_Rank` | | `UpdatePlayerData` (buffed rank) | `ID_Allegiance_RankBuffed` | `ID_Title`, `ID_Rank`, `ID_RankBuff` | | `UpdateMonarchData` (patron **is** monarch) | `ID_Allegiance_PatronSlashMonarchLabel` | — | | `UpdateMonarchData` (patron is not monarch) | `ID_Allegiance_MonarchLabel` | — | | `UpdateMonarchData` / `UpdatePatronData` / `UpdateVassalsData` | `ID_Allegiance_VassalExperiencePassedUp` | `ID_Value` | | `MakeSwearConfirmationDialog` | `ID_Allegiance_SwearConfirmation` | `ID_Player` | | `MakeAcceptSwearConfirmationDialog` | `ID_Allegiance_AcceptSwearConfirmation` | `ID_Player` | | `MakeBreakConfirmationDialog` | `ID_Allegiance_BreakConfirmation` | `ID_Player` | | `MakeKickConfirmationDialog` | `ID_Allegiance_KickConfirmation` | `ID_Player` | Eleven `ID_Allegiance_*` globals exist (`pseudo_c:1147783-1147793`); all eleven are consumed. The rank-buffed branch is selected by `CBaseQualities::InqInt(qualities, 0x1E, …)` compared against the profile rank (`ghidra@0x00491330:113-117`). **The allegiance login/logout chat lines are hard-coded English literals, not string-table entries:** `RecvNotice_AllegianceLogin @ 0x00492220` builds `` + `" is logged in.\n"` / `" has logged out.\n"` and calls `ClientSystem::AddTextToScroll(text, 0, true, 0)` (`ghidra@0x00492220:29-52`). Port note: that is a retail-faithfulness *and* localisation fact worth a divergence-register row if acdream chooses to table-ise it. ### 4.3 String tables (Q4) Every one of the 21 sites passes **table enum `0x10000001`**, which resolves to StringTable DID **`0x23000001`** by the same `enum + 0x13000000` relation the Options work established (`docs/research/2026-08-10-options-panel-structure.md` §1.5 pinned enum `0x10000003` → `0x23000003`). acdream already uses `0x23000001` for Character-Info, Combat, LinkStatus and FPS strings (`src/AcDream.App/UI/RetailUiRuntime.cs:1655,1696-1700`; `src/AcDream.App/UI/Layout/CombatUiController.cs:267`). The **two `UIOption_Checkbox` label/tooltip pairs** are different: they pass table enum `0x10000003` → **`0x23000003`** (`pseudo_c:156210`, `:156212`; `pseudo_c:157286`, `:157288`), which is the same table `CharacterOptionsPageController` already resolves (`src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs:84`). **How the ids are produced — this is the load-bearing part.** Unlike the Options tab *labels* (authored StringIds in the LayoutDesc), every `ID_*` above is a **runtime name hash**: the globals are `= 0x0` at link time and filled at startup by `ID_Fellowship_FellowName = compute_str_hash("ID_Fellowship_FellowName")` (`pseudo_c:767586-767682` for Fellowship, `:767876-767956` for Allegiance). acdream's `DatStringResolver.ComputeHash(key)` is the exact seam — the same one `MountCharacterInformationPanel` uses (`src/AcDream.App/UI/RetailUiRuntime.cs:1654-1655`). **No byte-verification of these ids is needed or possible; resolve by name.** > **Campaign carry-over caveat (the `0x2300000D` lesson).** Campaign CH found that the > TextFilter label family resolved *only* in `0x2300000D` despite the code's table enum > (`src/AcDream.App/UI/Layout/ChatOptionsPageController.cs:99-105`). The enum here says > `0x23000001` / `0x23000003`; **which table these 24 names actually resolve in is §8 item > U5** and must be swept, not assumed. ### 4.4 Button enable/disable — `gmFellowshipUI::UpdateButtons @ 0x0048E6C0` `SetState(1)` = enabled, `SetState(0xD)` = disabled (retail's own convention, matching `docs/research/2026-08-10-options-panel-structure.md` §3.4). | Button | Rule | |---|---| | Quit `0x1000027C` | **Always enabled** whenever `m_pFellowship != 0` | | Disband `0x10000280` | Enabled **iff you are the leader** | | Open/Close `0x1000027D` | Enabled **iff you are the leader** | | Leader `0x1000027B` | Leader-only, **and** disabled when the selected fellow *is you* | | Dismiss `0x1000027F` | Leader-only, **and** disabled when the selected fellow *is you*; disabled when nothing is selected | | Recruit `0x1000027E` | Enabled iff the currently-selected **world object** is a player, is not already a fellow, and the fellowship is **not full**. When you are *not* the leader it additionally requires `_open_fellow != 0` | | Create `0x10000274` | Disabled while the name-entry box is empty; enabled otherwise (`ghidra@0x004901C0`, message `0x12`/`0x44`) | Both BN zero-idioms decode cleanly: * `SetState(((eax - eax) & 0xc) + 1)` — real sequence `neg/sbb/and 3.. /add` around `Fellowship::IsFull`: **full → `0xD` (disabled), not full → `1` (enabled)**. Ghidra renders it honestly as `(-(iVar4 != 0) & 0xcU) + 1` (`ghidra@0x0048E6C0:97`). * `SetState(((ebx - 1) & 0xfffffff4) + 0xd)` with `ebx = (selectedFellow == you)`: **selected == you → `0xD`, otherwise → `1`**. `gmAllegianceUI` (`UpdateSwearButton @ 0x004908E0`, `UpdateBreakButton @ 0x004909D0`, `Update @ 0x00492670`): | Button | Rule | |---|---| | Swear `0x10000263` | Enabled **iff** you have **no** patron **and** a world object is selected **and** it is not you **and** it is a player **and** `AllegianceProfile::GetData(selected)` fails (i.e. they are not already in your allegiance). Otherwise `0xD` | | Break `0x10000264` | Enabled **iff** you have a patron | | Kick `0x10000265` | Enabled **iff** a vassal row is selected (`m_iidSelectedVassal != 0`); re-evaluated on every list-selection message | ### 4.5 Empty-state presentation (Q5) **Fellowship — an explicit two-frame swap.** `gmFellowshipUI::Update @ 0x0048F440`: ``` if (ClientFellowshipSystem::GetFellowshipSystem()->m_pFellowship == 0) delete this->m_pFellowship; this->m_pFellowship = null else copy the live CFellowship into this->m_pFellowship if (m_pFellowship == null) { UIListener::RegisterForGlobalMessage(this, 3) // per-frame tick, §7.1 m_pInAFellowshipFrame ->SetVisible(false) // 0x10000275 m_pNotInAFellowshipFrame->SetVisible(true) // 0x1000026B return // nothing else runs } UIListener::UnRegisterForGlobalMessage(this, 3) m_pNotInAFellowshipFrame->SetVisible(false) m_pInAFellowshipFrame ->SetVisible(true) … set m_pFellowshipName, Flush the list, rebuild rows, UpdateFellowSelection … ``` So the "no fellowship" state is **authored**, not synthesised: `0x1000026B` holds whatever retail wants you to see (the name-entry box `0x1000026F` and Create button `0x10000274` almost certainly live inside it — the id ordering `0x1000026B < 0x1000026F < 0x10000274 < 0x10000275` supports it, but **containment is a DAT fact**, §8 item **U6**). **Allegiance — no frame swap; per-block hiding + blanking.** There is no "not in an allegiance" frame. Instead: * `UpdateMonarchData`: if there is no monarch, **or the monarch is you**, → `m_pMonarchField->SetVisible(false)` and `m_pMonarchName` / `m_pMonarchFollowers` are set to the literal `L" "` (`ghidra@0x00491B40:78-99`). * `UpdatePatronData`: if there is no patron, **or your patron is the monarch**, → `m_pPatronField->SetVisible(false)` and `m_pPatronName = L" "`. *(Careful: the "patron == monarch" case hides the **patron** block and instead relabels the monarch block via `ID_Allegiance_PatronSlashMonarchLabel` and reveals `0x10000490`.)* * `UpdateVassalsData`: `UIElement_ListBox::Flush(m_pVassalListBox)` then `m_iidSelectedVassal = 0`; with no vassals the list simply stays empty. * The player line (`0x10000251`/`0x10000252`/`0x10000253`) is written unconditionally. **A live-vs-dim detail worth porting:** `m_pPatronField` is `SetState(1)` when the patron `IsLoggedIn` and `SetState(0xD)` otherwise; `m_pMonarchField` likewise. The vassal rows use a dedicated offline marker element `0x100004AA` instead. **Side effect neither panel's name suggests:** both allegiance updates gate chat targets — `gmCCommunicationSystem::SetTalkFocusEnabled(4, patronOnlineAndPresent)`, `(5, monarchOnlineAndPresent)`, `(6, anyVassalOnline)` (`ghidra@0x004917C0`, `ghidra@0x00491B40:216`, `ghidra@0x00492340`). Whatever the retail "talk focus" ids 4/5/6 mean exactly is not established here. --- ## 5. Dialogs (Q4) ### 5.1 The five dialogs, and they are all plain Confirmation dialogs Every one is built the same way — no dialog LayoutDesc is named in code; the type is selected by a `PropertyCollection` property: ``` StringInfo si; si.SetStringIDandTableEnum(, 0x10000001); si.AddVariable_String(ID_Player, name) PropertyCollection pc prop.SetPropertyName(0x8E); prop.value = 1 // dialog TYPE = 1 (Confirmation) pc.add(prop) prop.SetPropertyName(0xC5); prop.value = &si // MESSAGE pc.add(prop) context = DialogFactory::MakeDialogInCurrentUI(&pc) ``` | Dialog | Owner field | Guard / target | String | VA | |---|---|---|---|---| | **Swear Allegiance** | `m_swearContext` | Target = `ACCWeenieObject::selectedID`; name via `GetObjectName(NAME_APPROPRIATE)`; **no dialog if the name is empty**. On success stores `m_iidPossibleNewPatron = selectedID` | `ID_Allegiance_SwearConfirmation` | `0x004927B0` | | **Accept incoming swear** | `m_acceptSwearContext` | Raised by `RecvNotice_SwearAllegianceRequest(name, serverCtx)`; stores `m_uiAcceptSwearServerContextID = serverCtx` | `ID_Allegiance_AcceptSwearConfirmation` | `0x00492990` | | **Break Allegiance** | `m_breakContext` | Target = `AllegianceProfile::GetPatron(you)`; **no dialog if you have no patron or the name is empty** | `ID_Allegiance_BreakConfirmation` | `0x00492BF0` | | **Kick vassal** | `m_kickContext` | Target = `m_iidSelectedVassal` via `GetData`; stores `m_iidPossibleKickedVassal` | `ID_Allegiance_KickConfirmation` | `0x00492E10` | | **Accept fellowship invite** | `m_fellowRequestContext` | Raised by `RecvNotice_FellowshipRequest(name, serverCtx)`; stores `m_uiAcceptFellowRequestServerContextID` | `ID_Fellowship_FellowshipRequest` | `0x00490620` | **All five are re-entrancy-guarded by `if (context == 0)`** — a second request while one is open is dropped on the floor (returns `false`), it does not queue a second dialog. ### 5.2 On close `RecvNotice_CloseDialog(context, PropertyCollection&)` on both classes reads property **`0x8E`** (must be `1`) then property **`0x92`** (the boolean result), then dispatches on which of its context fields matches (`ghidra@0x00492060`, `ghidra@0x0048F2C0`, `pseudo_c:158340-158366`): | Dialog | On **accept** | On **reject** | |---|---|---| | Swear | `CM_Allegiance::Event_SwearAllegiance(m_iidPossibleNewPatron)` | nothing | | Accept-swear | `CM_Character::Event_ConfirmationResponse(**1**, m_uiAcceptSwearServerContextID, result)` — **sent either way**, carrying the boolean | same call, `result = 0` | | Break | `CM_Allegiance::Event_BreakAllegiance(GetPatron(you))` | nothing | | Kick | `CM_Allegiance::Event_BreakAllegiance(m_iidPossibleKickedVassal)` | nothing | | Fellow invite | `CM_Character::Event_ConfirmationResponse(**4**, m_uiAcceptFellowRequestServerContextID, result)` — **sent either way** | same call, `result = 0` | **Confirmation type ids: `1` = allegiance swear, `4` = fellowship.** Corroborated independently by the abort handlers, which each filter on their own type: `gmAllegianceUI::RecvNotice_AbortConfirmationRequest` acts only `if (arg2 == 1)` (`pseudo_c:157192`), `gmFellowshipUI`'s only `if (arg2 == 4)` (`pseudo_c:154765`). Abort calls `DialogFactory::CloseDialog(context)` and zeroes both the context and the stored server context. Destructors also `CloseDialog` their contexts (`pseudo_c:154900` for Fellowship). ### 5.3 acdream parity — the machinery already exists, exactly `src/AcDream.App/UI/Layout/RetailDialogData.cs:9-38` already defines the identical property map and enum: ``` Priority = 0x8D Type = 0x8E ← the property both panels set to 1 AcceptLabel = 0x90 RejectLabel = 0x91 ConfirmationResult = 0x92 ← the property both panels read on close QueueKey = 0xC3 Message = 0xC5 ← the StringInfo both panels set enum RetailDialogType { Confirmation = 1, … } ``` and `RetailDialogFactory.MakeDialog(data, callback)` (`src/AcDream.App/UI/Layout/RetailDialogFactory.cs:75`) is the port of `MakeCallbackDialogInCurrentUI`. **No new dialog work is needed for Campaign FA** beyond wiring five `RetailDialogData` builders and their callbacks. --- ## 6. Open paths, wire events, and collisions (Q5 + Q3's collision ask) ### 6.1 Open path — keybind only; **there is no toolbar button** | Panel | Input action | Default key | Anchors | |---|---|---|---| | Allegiance | **`0x1000000E` `ToggleAllegiancePanel`** | **F3** | `retail-default.keymap.txt:141`; `keymap-default.txt:132` (`F3 scan=0x3D Action=0x1000000E Activation=0x03`) | | Fellowship | **`0x1000000F` `ToggleFellowshipPanel`** | **F4** | `retail-default.keymap.txt:142`; `keymap-default.txt:133` (`F4 scan=0x3E Action=0x1000000F Activation=0x03`) | The retail toolbar's full action row is `0x1000000D`, `0x10000010`, `0x10000013`, `0x100001E7`, `0x10000016`, `0x1000001A`, `0x10000019` (`docs/research/2026-08-10-options-panel-structure.md` §2.2, from the committed `toolbar_21000016.json` fixture) — **neither `0x1000000E` nor `0x1000000F` is present.** Retail opens these two panels from the keyboard only. > **Warning — numeric-namespace collision.** `0x1000000E` and `0x1000000F` are *also* > element class ids (`gmKeyboardUI` and `gmPowerbarUI`, `pseudo_c:225419`, `:222537`) and > *also* DB-type enums. Grepping those literals in the pseudo-C returns mostly false > positives; the keymap files are the authority for the action-id reading. By the Options-panel pattern the panel root should carry `P0x57 = ` and any close button `P0x12 = ` (`docs/research/2026-08-10-options-panel-structure.md` §2.3). I did **not** find a `GetAttribute_Enum(this, 0x57, …)` read site in this binary either — the same UNVERIFIED status that doc records. **§8 item U7.** Also observed: `gmAllegianceUI::ListenToGlobalMessage(1, 0x27)` clears the vassal list selection (`UIElement_ListBox::SetSelectedItem(m_pVassalListBox, nullptr, true)`, `ghidra@0x004908B0`). Global message `1` is "an input action fired"; action `0x27` is in the small `0x0000xxxx` engine-action family (cf. `Action=0x00000029` = W in `keymap-default.txt:24`) and its identity is **not established** here. §8 item **U8**. ### 6.2 Element messages and wire events `gmFellowshipUI::ListenToElementMessage @ 0x004901C0` switches on `idMessage - 1` via `lookup_table_490418[0x44]` + `jump_table_490408` (`pseudo_c:156546-156638`), then on `idElement - 0x10000274` via `jump_table_49045c[0xD]`: | `idMessage` | Meaning | Handling | |---|---|---| | `1` | button clicked | the seven-way element switch below | | `4`, `0x43` | ListBox selection changed | read row's `0x1000000D` → `ACCWeenieObject::SetSelectedObject` (+ target-mode execute) → `m_iidSelectedFellow` → `UpdateButtons` | | `0x12`, `0x44` | text changed | empty name-box → Create `SetState(0xD)`, else `SetState(1)` | | Element (`+offset`) | Action | Wire | |---|---|---| | `0x10000274` (+0) | `CreateFellowship` | `CM_Fellowship::Event_Create(name, PlayerModule::FellowshipShareXP())` @ `0x006A67A0` | | `0x1000027B` (+7) | `AssignLeadershipToFellow(m_iidSelectedFellow)` | `CM_Fellowship::Event_AssignNewLeader` @ `0x006A5F70` | | `0x1000027C` (+8) | Quit — **if you are the leader it first hands leadership to `Fellowship::GetNonLeaderFellowID`** | `CM_Fellowship::Event_Quit(**0**)` @ `0x006A61E0` | | `0x1000027D` (+9) | flip `_open_fellow` locally, then send | `CM_Fellowship::Event_ChangeFellowOpeness(newState)` @ `0x006A6040` | | `0x1000027E` (+0xA) | `RecruitFellow(ACCWeenieObject::selectedID)` | `CM_Fellowship::Event_Recruit` @ `0x006A62B0` | | `0x1000027F` (+0xB) | `DismissFellow(m_iidSelectedFellow)` | `CM_Fellowship::Event_Dismiss` @ `0x006A6110` | | `0x10000280` (+0xC) | Disband | `CM_Fellowship::Event_Quit(**1**)` @ `0x006A61E0` | **Quit and Disband are the same wire event with a different flag** — `Event_Quit(0)` vs `Event_Quit(1)`. Offsets +1..+6 (`0x10000275`–`0x1000027A`) fall to the default arm, which is consistent with them being the frame, name text, headers, list and scrollbar. `CreateFellowship @ 0x0048F730` also runs the typed name through `ACCharGenData::FormatName` and **writes the formatted name back into the entry box** before sending — a visible retail behaviour. `gmAllegianceUI::ListenToElementMessage @ 0x00493030` is far simpler (`ghidra@0x00493030`): `idMessage == 1` → `0x10000263`/`0x10000264`/`0x10000265` open the Swear/Break/Kick dialogs; `idMessage == 4` → read the row's `0x10000001` into `m_iidSelectedVassal` and enable/disable Kick. **No text messages, no `0x43`/`0x44` arms.** ### 6.3 Subscription / refresh lifecycle | | Fellowship | Allegiance | |---|---|---| | On show | `OnVisibilityChanged(true)` → `CM_Fellowship::Event_UpdateRequest(1)` (`pseudo_c:154778`) | `OnVisibilityChanged(true)` → `Event_UpdateRequest(1)` + `ClientUISystem::IncrementBusyCount` (hourglass) if not already awaiting | | On hide | `Event_UpdateRequest(0)` | `Event_UpdateRequest(0)` + `DecrementBusyCount` | | On `PostInit` | registers 9 notice handlers + global message 1, then calls `Update` | registers 9 notice handlers + global message 1 + **two quality handlers**, then sends `Event_UpdateRequest(1)` and sets `m_bAwaitingUpdate` | | Quality triggers | — | `RegisterQualityHandlerForThePlayer(IID_StatType /*7*/, 0x19)` and `(7, 0x1A)` = `PropertyInstanceId.Patron = 25` / `Monarch = 26` (`src/AcDream.Core/Properties/PropertyInstanceId.cs:57-58`). Either changing → `Event_UpdateRequest(1)` | | Update arrival | `RecvNotice_FellowshipUpdate` / `_FellowAdded` / `_FellowQuit` / `_FellowDismissed` / `_FellowUpdated` / `_FellowshipDisbanded` | `RecvNotice_AllegianceUpdate` / `_AllegianceUpdateAborted` / `_EnchantmentsChanged` / `_PlayerDescReceived` → `Update` | `gmAllegianceUI::Update` runs, in order: `UpdatePlayerData`, `UpdateMonarchData`, `UpdatePatronData`, `UpdateVassalsData`, `UpdateSwearButton`, `UpdateBreakButton`, then Kick's own enable check (`pseudo_c:158677-158692`). ### 6.4 Element-id collision sweep **Code-side (complete for the binary).** Every element id in §3 was counted across the whole 1.4 M-line pseudo-C. Each appears **only inside its own class's functions** (1–2 hits, both in the same class). No cross-class code collision exists. **Layout-side (partial — 33 committed fixtures only).** I walked every `Id` field in all 33 JSON layout fixtures under `tests/AcDream.App.Tests/UI/Layout/fixtures/` against the full candidate range `0x10000251`–`0x1000028A` (both panels' ids plus every unreferenced id between them) — **zero collisions**. That covers options (`0x2100002B`, `0x21000028`, `0x21000029`, `0x2100002A`, `0x2100005C`), keyboard config (`0x21000009`), toolbar, chat, character, inventory, paperdoll, spellbook, combat, radar, vitals, vitae, effects, appraisal, dialogs, vendor, indicators, powerbar, link-status, mini-game, smartbox-FPS. **This is not a full clearance.** The campaign's `0x10000211`-in-two-layouts precedent means the real test is the panel's own resolved subtree against the *live* trees acdream mounts, which requires the DAT dump — §8 item **U9**. Two specific risks to check there: 1. `0x10000490`, `0x10000492`, `0x100004AA` sit in a different, much busier numeric band than the rest of the Allegiance panel and are the most likely to collide. 2. `0x10000492` is looked up under **two different parents** in retail (`m_pMonarchField/0x10000490` and `m_pPatronField`) — i.e. **retail itself authors two physical elements under one id**, and both lookups are scoped. A flat `layout.FindElement(0x10000492)` will pick the wrong one. This is exactly the Apply/Reset/Defaults hazard OP4 already solved with a scoped `UiElement.FindDescendant` (`src/AcDream.App/UI/Layout/OptionsPanelController.cs:67-79`) — **use the scoped search from day one.** ### 6.5 Shared-state collision with the Options panel (not an id collision — a *state* one) Five of the six checkboxes these panels host are **duplicates of rows already live on the Options → Character tab**: | Panel widget | `PlayerOption` | Also authored on the Character tab | |---|---|---| | Allegiance `0x10000262` | `1` `IgnoreAllegianceRequests` | `CharacterOptionsPageController.cs:195` | | Fellowship `0x10000270` | `2` `IgnoreFellowshipRequests` | `:196` | | Fellowship `0x10000272` | `0x0F` `FellowshipShareXP` | `:198` | | Fellowship `0x10000273` | `0x11` `FellowshipShareLoot` | `:199` | | Fellowship `0x10000271` | `0x12` `FellowshipAutoAcceptRequests` | `:200` | All five numeric values match acdream's enum exactly (`src/AcDream.Core.Net/Messages/SocialActions.cs:365-382`). Retail's `UIOption_Checkbox` writes through `PlayerModule::SetOption` and applies **immediately on click** (`docs/research/2026-08-10-options-panel-structure.md` §3.7), so both surfaces are live views of one value — and retail's `CPlayerModule::OnChanged(PlayerOption)` enforces the `IgnoreFellowshipRequests` ↔ `FellowshipAutoAcceptRequests` mutual exclusion centrally (already ported at `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs:794-834`). **Port implication:** these six checkboxes must bind to `RuntimeCharacterOptionsState`, not to panel-local state, and both surfaces must refresh when the other writes. ### 6.6 Widget-kind parity in acdream | Retail Type | acdream factory arm | Status | |---|---|---| | `1` Button | `DatWidgetFactory` (button arm) | ✔ | | `5` `UIElement_ListBox` | `UiTemplateListBox` (`DatWidgetFactory.cs:105`) | ✔ built, **but see gap below** | | `7` `UIElement_Meter` | `BuildMeter` → `UiMeter` (`DatWidgetFactory.cs:108`) | ✔ (`UiMeter.Fill` is the `SetAttribute_Float(0x69)` seam) | | `0xC` `UIElement_Text` | `BuildText` (`DatWidgetFactory.cs:122`) | ✔ | | `0x10000035` `UIOption_Checkbox` | `BuildCheckbox` (`DatWidgetFactory.cs:125`) | ✔ | **Gap found:** `UiTemplateListBox` (`src/AcDream.App/UI/UiTemplateListBox.cs:81-229`) exposes `AddItemFromTemplateList(index)` and `AddPrebuiltRow(row)` — but **no `Flush`, no selection model (`SetSelectedItem`/`GetSelectedIndex`), and no per-row instance-id attribute**. Both panels need all three (`Flush` on every `Update`; selection to drive Dismiss/Leader/Kick; the row attribute `0x1000000D`/`0x10000001` as the identity key). This is a bounded, real prerequisite for Campaign FA implementation — flagging it here so the planner sizes it. Retail detail that matters for the Open/Close button: `UpdateButtons` calls `UIElement_Text::SetText((UIElement_Text*)m_pFellowOpenButton, …)` — i.e. retail's `UIElement_Button` **derives from** `UIElement_Text` and a button's caption is set through the text API (`ghidra@0x0048E6C0:115,129`). acdream's button caption seam must allow the same runtime relabel. --- ## 7. Notes and loose ends worth carrying ### 7.1 `gmFellowshipUI::ListenToGlobalMessage` is a folded no-op — and message 3 is the frame tick `symbols.json` gives `gmFellowshipUI::ListenToGlobalMessage = 0x004F5860`, which is an **empty function shared by identical-COMDAT folding** with `gmPanelUI::ListenToGlobalMessage`, `gmPanelUI::RecvNotice_PlayerDescReceived`, `NoticeHandler::RecvNotice_ItemAttributesChanged` and `NoticeHandler::RecvNotice_DisplayWeenieError` (`pseudo_c:248560` / `ghidra@0x004F5860`). So Fellowship handles **no** global message directly, despite `PostInit` registering for message 1 and `Update` toggling registration for message 3. Global message **3** is byte-identified: `UIElementManager::UseTime @ 0x0045CFD0` calls `BroadcastGlobalMessage(this, 3, 0)` once per frame, between `CheckTooltip` and `DrawDirtyRegions` (`pseudo_c:102945`). So the register/unregister pattern is "**tick me every frame while I have no fellowship**" — plausibly for the name-entry box's caret, but the *effect* is UNVERIFIED because Fellowship's own handler is empty. Recorded, not asserted. §8 item **U8**. ### 7.2 `RegisterNoticeHandler` operands are code addresses, not ids `PostInit` registers 9 handlers on each panel with operands like `0x4DD20F`, `0x4DD210`, `0x4DD246`, `0x4DD1EF`, `0x186A4` (Fellowship, `pseudo_c:156384-156392`) and `0x4DD1F0`, `0x4DD201`, `0x4DD205`–`0x4DD208`, `0x4DD20D`, `0x4DD1EF`, `0x186A4` (Allegiance, `pseudo_c:157398-157406`). The `0x4DD…` values are inside the code section, i.e. they are **thunk/notice-slot addresses**, not notice ids; `0x186A4` (= 100004) looks like a genuine numeric id. Same shape as `gmConfigUI`'s `RegisterNoticeHandler(0x4DD276, …)` recorded in the Options doc. Not decoded here; not needed for panel structure. §8 item **U8**. ### 7.3 Two different row-identity attribute keys Fellowship rows key on `0x1000000D`; Allegiance rows key on `0x10000001`. Both go through `SetAttribute_InstanceID` / `GetAttribute_InstanceID`. Do **not** unify them in the port — they are different authored attribute names and a shared row helper must take the key as a parameter. (Both values also exist as element class ids — `0x1000000D gmExternalContainerUI`, `0x10000001 gmUIElement_BurdenIndicator` — a different namespace. Don't cross the wires.) --- ## 8. Unknowns for the coordinator Everything below is genuinely not established from the decomp. **U2** is the only one that blocks a mount. | # | Unknown | Why the decomp can't answer it | Verification recipe | |---|---|---|---| | **U1** | The two panels' own **LayoutDesc DIDs** | Both classes are instantiated from an authored element by `Type`; no DID is loaded in code (§2.1) | DAT sweep: enumerate LayoutDescs and find the two whose element tree contains `Type == 0x1000002D` / `Type == 0x1000002C` | | **U2** | **Which `0x2100006E` slot element** hosts each panel, and its `P0x10000029` panel id | `SetupChildren` hard-codes the 16 slot ids but reads the key from the DAT (§2.2) | For each of the 16 ids in §2.2's table, `LayoutImporter.ImportInfos(dats, 0x2100006E, slotId)` and read (a) `P0x10000029` on the slot, (b) the subtree root's `Type`. Report the full 16-row table — it also closes the 10 currently-unidentified `RetailPanelCatalog` entries in one pass | | **U3** | **Geometry, media, fonts, base refs** for every element in §3.1/§3.2 | Code reveals ids, not layout | Dump both slot subtrees to fixtures (`fellowship_2100006E_.json`, `allegiance_2100006E_.json`) exactly like `options_panel_2100006E_1000018D.json` | | **U4** | The **code-untouched element ids**: Fellowship `0x10000277`, `0x10000278`, `0x1000027A`, `0x10000281`, `0x10000282`; Allegiance `0x10000254`, `0x10000259`, `0x1000025B`, `0x1000025D`–`0x1000025F`, `0x10000261`, `0x10000266`, `0x10000267`, `0x1000026A` | Static labels, column headers, scrollbars and borders never appear in code | Falls out of U3's dump. Expect the two `…27A` / `…261` candidates to be the two ListBox scrollbars (`ScrollbarElementId`) | | **U5** | Which StringTable the 24 `ID_Fellowship_*` / `ID_Allegiance_*` / 6 `ID_PlayerOption_*` names actually resolve in | Code says table enum `0x10000001` → `0x23000001` (and `0x10000003` → `0x23000003`), but the Campaign CH `0x2300000D` precedent shows the enum can be wrong | Exhaustive sweep: for each of the 24 names, `ComputeHash(name)` and probe every `0x23xxxxxx` table (and `0x78000000`, the DAT caption catalog CH used for chat captions). Report the resident table per family, not per name | | **U6** | Whether `0x1000026F` (name box) and `0x10000274` (Create button) are **children of** `0x1000026B` (`m_pNotInAFellowshipFrame`), and whether the list + six buttons are children of `0x10000275` | `GetChildRecursive` is called from the panel root, so containment is invisible to code | Falls out of U3's dump. It decides whether acdream's empty-state is one `SetVisible` or many | | **U7** | Whether the two panel roots carry `P0x57 = 0x1000000E` / `0x1000000F`, and whether either panel authors a close (X) button with `P0x12 = ` | No `GetAttribute_Enum(this, 0x57, …)` read site exists in the binary — the same UNVERIFIED status the Options doc records (its §2.3) | Falls out of U3's dump (read `P0x57` on the roots and `P0x12` on any `Type 1` child not in §3's tables) | | **U8** | Three low-stakes semantics: input action `0x27` (clears vassal selection, §6.1); the `RegisterNoticeHandler(0x4DD…)` operands (§7.2); the *effect* of Fellowship's message-3 registration given its empty handler (§7.1) | Would need either a cdb trace or notice-table reversing | Only worth doing if a behaviour gap shows up in a connected gate | | **U9** | **Full element-id collision clearance** across every layout acdream mounts | I could only sweep the 33 committed fixtures (§6.4) | After U3, run both panels' resolved id sets against every currently-mounted layout's id set. Prioritise `0x10000490`, `0x10000492`, `0x100004AA`, and treat `0x10000492`'s **intra-panel** duplication as a hard requirement for scoped lookups | | **U10** | The row-template arrays: what LayoutDesc DID + element id sits at **index 0** of `P0x64` on `0x10000279` (fellows) and `0x10000260` (vassals) | Template arrays are authored on the ListBox (Options doc §1.5) | Falls out of U3's dump — read `P0x64` on both ListBoxes and dump the referenced template elements as their own fixtures | | **U11** | **[FA3 fix-round addendum, 2026-08-12, mechanism SF-1.]** What a repeat F3/F4 press does when the social panel is already open on the OTHER tab — switch, or close-then-reopen, or something else | No `0x1000000E`/`0x1000000F` `OnAction` consumer exists anywhere in the binary: no `GetAttribute_Enum(this, 0x57, …)` read site, `ClientUISystem::OnAction`'s `m_InputAction > 0x7c` branch handles exactly three unrelated ids, and `gmPanelUI::ListenToGlobalMessage` is COMDAT-folded onto a no-op (`NoticeHandler::RecvNotice_DisplayWeenieError @0x004F5860`) | Exhausted for this campaign (see `SocialPanelController.IsShowingAllegiance`'s own doc). acdream ships its own `OpenSpellbook`-precedent convention (switch, not close) — a cdb trace against a live retail client (Step -1 in CLAUDE.md's workflow) is the only remaining way to settle it, and is not worth the cost unless a connected gate reports a mismatch | --- ## 9. Executive summary 1. **Fellowship and Allegiance are two separate `UIElement` classes** — `gmFellowshipUI` (class id `0x1000002D`, `Register @ 0x0048E6A0`) and `gmAllegianceUI` (`0x1000002C`, `0x004911F0`), both deriving from `UIElement_Field` — **not tabs of a social panel; no `gmSocialUI` exists in the binary.** Each mounts as its own sibling in the shared `gmPanelUI` page stack inside floaty host `0x2100006E`, exactly like Character Info (`0x10000183`) and Options (`0x1000018D`), and inherits retail's one-active-sibling visibility contract for free. 2. **The complete element inventory is recovered from code** — 19 bound elements + a 9-part fellow row template (name, level/XP%, and health/stamina/mana meter+text triples) for Fellowship; 15 bound elements + a 3-part vassal row for Allegiance, whose vassal list is **flat (direct vassals only), not a tree**. Rows on both panels are built by `AddItemFromTemplateList(listbox, 0, nullptr)` and keyed by a row instance-id attribute — `0x1000000D` on Fellowship, `0x10000001` on Allegiance. The fellowship name is an **inline editable text field** (`0x1000026F`), and "open fellowship" is a **button whose caption swaps** (`0x1000027D`), not a checkbox. 3. **All five dialogs are plain Confirmation dialogs** built from `PropertyCollection` `0x8E = 1` (Type) + `0xC5` (Message) and answered by `0x92` (Result) — precisely the contract `RetailDialogData`/`RetailDialogFactory` already implements. Confirmation type ids are **1 = allegiance swear**, **4 = fellowship**. Every panel string resolves by **runtime name hash** (`compute_str_hash("ID_Fellowship_…")`) in table enum `0x10000001` → `0x23000001` (the two option checkboxes use `0x10000003` → `0x23000003`), so acdream's existing `DatStringResolver.ComputeHash` seam is the whole job — but which table the names *actually* live in still needs the `0x2300000D`-style sweep. 4. **Open path is keyboard-only:** `ToggleAllegiancePanel = 0x1000000E` (**F3**) and `ToggleFellowshipPanel = 0x1000000F` (**F4**); neither action appears on the retail toolbar. Empty state differs by panel — Fellowship swaps two **authored** frames (`0x1000026B` no-fellowship / `0x10000275` in-fellowship), Allegiance has no frame swap and instead hides the monarch/patron blocks and blanks their text to `L" "`. 5. **Blocking unknown: exactly one.** Which of the 16 `gmPanelUI::SetupChildren` slot ids hosts each panel (§8 **U2**) — everything else is a nice-to-have dump. Two findings the planner should size now: `UiTemplateListBox` has **no `Flush`, no selection model and no row instance-id**, all three of which both panels need; and retail authors **`0x10000492` twice inside the Allegiance panel** (under the monarch block and under the patron block), so scoped `FindDescendant` lookups are mandatory from day one, not a later fix. --- ## 10. COORDINATOR ADDENDUM (2026-08-11) — U2 CLOSED by live-DAT dump; §2's mounting call CORRECTED `tests/AcDream.App.Tests/UI/Layout/FaPanelSlotProbeTests.cs` (`ACDREAM_PROBE_LIVE_MOUNT=1`) dumped all 16 authored slots + `0x10000186` against the installed DATs. Primary results: **Fellowship and Allegiance are NOT separate gmPanelUI siblings — they are two of FOUR pages of ONE tabbed social panel** at slot **`0x1000018F`**, `P0x10000029` panel id **12**, root Type **8** (the same authored tab-host class OP2/OP3 already ported). Pages, with their `RegisterElementClass` identities: | Tab button (x-order) | Page element | Page class | Panel | |---|---|---|---| | `0x1000028C` (x=0) | `0x10000513` | `0x10000045` = `gmFriendsUI` (`@0048e21a`) | Friends | | `0x1000028E` (x=72) | `0x10000291` | `0x1000002C` = `gmAllegianceUI` | **Allegiance** | | `0x10000512` (x=144) | `0x10000292` | `0x1000002D` = `gmFellowshipUI` | **Fellowship** | | `0x1000053B` (x=206) | `0x1000054A` | `0x10000047` = `gmSquelchUI` (`@0048d04a`) | Squelch | (The button→page pairing above is by authored order and needs the tab TABLE property (0x2E) read to pin exactly — one fixture dump closes it, same as U3.) **[FA3 addendum, 2026-08-12: the x-order guess above is WRONG.** The FA3 fixture dump (`social_panel_2100006E_1000018F.json`) and its live-mount probe (`SocialPanelLiveMountProbeTests`) read the real authored `0x2E` tab table, corroborated independently by each BUTTON's own resolved caption and each PAGE's own `P0x57` property (which lines up with the real F3/F4 `ToggleAllegiancePanel`/`ToggleFellowshipPanel` ActionMap ids on the Allegiance/Fellowship pages specifically — a fact this table's own x-order guess had no way to check). The real pairing: ``` button 0x1000028C ("Allegiance") -> page 0x10000291 (Allegiance) DEFAULT button 0x1000028E ("Fellowship") -> page 0x10000292 (Fellowship) button 0x10000512 ("Friends") -> page 0x10000513 (Friends) button 0x1000053B ("Squelch") -> page 0x1000054A (Squelch) ``` The panel's authored default tab is **Allegiance**, not Friends. See `src/AcDream.App/UI/Layout/SocialPanelController.cs`'s own class doc for the full citation.] Every lane-C allegiance element id is CONFIRMED present under page `0x10000291`: swear `0x10000263` / break `0x10000264` / kick `0x10000265` (three 88×33 buttons at y=562), vassal ListBox `0x10000260` (Type 5, 279×350) + scrollbar `0x10000261`, monarch/patron/self blocks `0x10000250`/`0x10000255`/`0x1000025A`, meter `0x10000262`. The fellowship page carries the §3.1 inventory including the two authored empty/full frames `0x10000275`/`0x1000026B`. The full 16-slot table also closes the RetailPanelCatalog unknowns: `0x10000181`=1 gmAbuseUI, `0x10000182`=2 gmBookUI, `0x10000187`=8 gmLinkStatusUI, `0x10000188`=9 gmMiniGameUI, `0x1000018F`=12 the social panel, `0x10000189`=14 gmUrgentAssistanceUI, `0x1000018A`=15 gmVitaeUI, `0x1000018C`=16 gmMapUI+gmHouseUI pages, `0x10000559`=25 gmJournalUI/gmPageListUI/gmContractsUI. `0x10000186` does not resolve (IMPORT NULL) — consistent with §2.2's "conspicuously absent" flag. **Plan impact:** Campaign FA mounts ONE panel (id 12) with the OP3 `OptionsPanelController` recipe (Type-8 host, `ActivateTabBehavior`, per-page scoped controllers); Friends and Squelch pages ride along and map naturally onto `RuntimeCommunicationState`'s existing friends/squelch owners (J4.1) — scope decision deferred to the plan. U3/U4/U6/U7/U10's fixture dump should target slot `0x1000018F`.