research: Campaign FA lanes A-D + the U2 slot-table probe (social panel found)

Four Opus research lanes for the Fellowship & Allegiance campaign:
panel structure, fellowship wire (two BN zero-folds broken by byte
decode: IsFull >= 9, the x87 XP-share table capping at 2.8x), allegiance
wire (27+5 messages binary-verified; tree assembly discard/reversal
rules; ACE zeroed-field caveats), and the acdream seams audit (H.2
scaffolding inventory, J-owner recommendation, AD-78 dimmed-row
inventory, bot-gate requirements).

Coordinator U2 closure (FaPanelSlotProbeTests, live DATs): Fellowship
and Allegiance are two of FOUR pages of ONE tabbed social panel — slot
0x1000018F, panel id 12, Type-8 host — alongside gmFriendsUI and
gmSquelchUI; lane A's separate-siblings mounting call is corrected in
its addendum, and the full 16-slot dump closes every unidentified
RetailPanelCatalog entry (Abuse/Book/LinkStatus/MiniGame/UA/Vitae/
Map+House/Journal).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-11 23:42:19 +02:00
parent 5eca35b706
commit 1226f289f3
5 changed files with 4009 additions and 0 deletions

View file

@ -0,0 +1,901 @@
# Campaign FA — lane D: acdream seams for Fellowship & Allegiance
**Status:** RESEARCH ONLY (2026-08-11). Read-only audit of our own tree; no
retail decomp claims here (that is lanes AC). Every claim is cited
`file:line` against the worktree
`.claude/worktrees/eloquent-hugle-42119e`.
**Purpose:** so the FA plan places new code correctly the first time.
---
## 0. Executive orientation — what already exists
FA is NOT greenfield. The tree already carries a partial, unwired
foundation laid in H.2 (2026-04-18) and extended incidentally by
Campaigns CH and OP:
| Already present | Where | Wired to production? |
|---|---|---|
| All 11 S→C event ids | `src/AcDream.Core.Net/Messages/GameEventType.cs:16,19,29,30,55,56,57,84,85,100,101,102` | **NO parsers, NO handlers** |
| Fellowship C→S builders (5) | `src/AcDream.Core.Net/Messages/SocialActions.cs:123-168` | **NO `WorldSession.Send*` wrapper, NO caller** |
| Allegiance C→S builders (2) | `src/AcDream.Core.Net/Messages/AllegianceRequests.cs:34-46` | **NO `WorldSession.Send*` wrapper, NO caller** |
| Client allegiance tree model | `src/AcDream.Core/Allegiance/AllegianceTree.cs:56-162` + XP passup `:172-183` | **NO writer; zero references outside its own file** |
| `RetailLogTextType.Allegiance 0x12` / `.Fellowship 0x13` | `src/AcDream.Core/Chat/RetailLogTextType.cs:44-45` | Colors already conformance-pinned (CH) |
| `RuntimeChatChannel.Fellowship/.Allegiance/.Vassals/.Patron/.Monarch/.CoVassals` | `src/AcDream.Runtime/GameRuntimeCommands.cs:63-90` | **YES** — outbound chat works today (`DirectGameRuntimeCommandAdapter.cs:1014-1062`) |
| `InputAction.ToggleAllegiancePanel` (F3) / `.ToggleFellowshipPanel` (F4) | `src/AcDream.UI.Abstractions/Input/InputAction.cs:103-106`, defaults `KeyBindings.cs:209-210`, retail ActionMap ids `RetailActionIdentityTable.cs:193-194` | **NO handler**`RetailUiRuntime.HandleInputAction` (`RetailUiRuntime.cs:548-560`) has no case for either |
| 6 dimmed Grouping-group option rows | `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs:193-201` | Store-only (AD-78) |
| Retail mutual exclusion (MF-2) | `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs:818-834` | **YES** — already live |
| Confirmation types 1 and 4 (allegiance/fellowship) placeholder | `src/AcDream.App/UI/GameplayConfirmationController.cs:36-40` | Generic dialog only; explicitly awaiting FA panels |
`AllegianceRequests.BuildSwear/BuildBreak` and every
`SocialActions.BuildFellowship*` have **zero production callers** — a
whole-tree grep returns only their own definitions and their tests. FA
must add the `WorldSession.Send*` wrappers (§3) before anything can reach
the wire.
Open issue ledger: `docs/ISSUES.md:12961` (**#L.4**, "Allegiance,
Fellowship, and World remain separate open panel features") and
`docs/ISSUES.md:524` (**#360**, the ~22 unported `@allegiance`/`@house`
subcommands).
---
## 1. Runtime ownership — where fellowship/allegiance state belongs
### 1.1 The J-pattern, as actually implemented
`GameRuntime` builds every gameplay owner in one ordered, fault-injectable
construction transaction (`src/AcDream.Runtime/GameRuntime.cs:156-320`),
exposes each as a public `*Owner` property (`:419-429`), and exposes a
**read-only typed borrowed view** per owner (`:463-470`). Convergence is
proved by a per-owner `CaptureOwnership()` rolled up through
`RuntimeGameplayOwnership.Capture`
(`src/AcDream.Runtime/Gameplay/RuntimeGameplayOwnership.cs:23-44`) →
`RuntimeSimulationOwnership.Capture`
(`src/AcDream.Runtime/RuntimeSimulationOwnership.cs:23-44`) →
`GameRuntime.CaptureOwnership` (`GameRuntime.cs:545-567`).
Every new J-owner therefore costs **eight** mechanical edits:
1. a `Runtime<X>State` class with `IDisposable`, `ResetSession()`,
`CaptureOwnership()`;
2. a `Runtime<X>OwnershipSnapshot` record struct with an `IsConverged`
expression (template: `RuntimeCharacterState.cs:9-51`);
3. construction + `construction.Own(...)` + a new
`GameRuntimeConstructionPoint` fault point (`GameRuntime.cs:200-214` is
the two-line template);
4. the `*Owner` property and its `IRuntime<X>View` projection
(`GameRuntime.cs:419-429`, `:463-470`);
5. a field + a new `RuntimeGenerationResetStage` ordinal + a `Drain` case
in `RuntimeGenerationReset`
(`src/AcDream.Runtime/RuntimeGenerationReset.cs:21-44`, `:97-121`,
`:236-349`);
6. inclusion in `RuntimeGameplayOwnershipSnapshot`
(`RuntimeGameplayOwnership.cs:8-21`);
7. a shutdown-ledger step in `GameRuntime`'s ordered teardown
(`GameRuntime.cs:703-752` — the numbered `switch` + `=>` pair must stay
in lockstep);
8. a field on `RuntimeStateCheckpoint`
(`src/AcDream.Runtime/GameRuntimeViews.cs:223-241`) and the matching
`RuntimeTraceRecorder.AddCheckpoint` string
(`src/AcDream.Runtime/GameRuntimeEvents.cs:154-224`) if the state is to
appear in connected-gate artifacts.
### 1.2 Is `RuntimeCommunicationState` the natural home? — NO
`RuntimeCommunicationState`
(`src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs:55-262`) owns
`ChatLog`, `SpewBoxState`, `ChatCommandTargetState`, `TurbineChatState`,
`FriendsState`, `SquelchState`, `ChatWindowState`, plus the
`IRuntimeCommunicationEventSource` chat stream (`:327-506`). Its J4.1
closeout describes its scope as exactly "chat transcript, reply/retell
command targets, negotiated Turbine rooms and context cookies, friends,
and squelch database"
(`docs/research/2026-07-26-slice-j4-1-communication-state.md:9-11`).
Three reasons FA state does **not** belong there:
- **Lifetime mismatch.** Friends/squelch are cleared at their own reset
stages (`RuntimeGenerationReset.cs:277-282`) and are pure client-side
lists. Fellowship membership is a server-authoritative *roster with live
per-member vitals*, and allegiance is a *tree*. Folding them in makes
`RuntimeCommunicationOwnershipSnapshot`
(`RuntimeCommunicationState.cs:20-48`) a 20-field grab bag whose
`IsConverged` no longer reads as one lifetime.
- **The J4 precedent is one owner per coupled graph, not per theme.** J4
split inventory (J4.2), character (J4.3), and communication (J4.1) apart
even though all three are "player state"; J4.3's stated rationale is that
spellbook + local player "form one lifetime group because vital maxima
read active enchantments from this exact spellbook"
(`RuntimeCharacterState.cs:53-57`). Fellowship and allegiance have no
such read-coupling to chat.
- **The two FA subsystems are themselves only loosely coupled.**
Fellowship is a flat ≤9-member roster with a disband/quit/recruit
lifecycle; allegiance is a persistent tree with monarch/patron/vassal
edges and an XP-passup model that survives logout.
### 1.3 Recommended ownership shape
**Two sibling owners under `GameRuntime`, not one, and not a child of
Communication:**
```
GameRuntime
├── FellowshipOwner : RuntimeFellowshipState (new)
│ └── borrows RuntimeEntityObjectLifetime (member guid → ClientObject)
└── AllegianceOwner : RuntimeAllegianceState (new)
└── owns the ported AcDream.Core.Allegiance.AllegianceTree
```
Rationale and precedent:
- **Separate owners** because the reset semantics differ: fellowship is
session-scoped (a disconnect drops you from the fellowship server-side —
clear it at reset, same class as `_inventory.ResetExternalContainer`,
`RuntimeGenerationReset.cs:248-261`), whereas allegiance survives
reconnect and is re-seeded from `PlayerDescription`/`AllegianceUpdate`
(same class as `RuntimeCharacterOptionsState`'s `HasServerSeed` latch,
`project_settings_options_digest.md` DO-NOT-RETRY row "Applying a
pre-seed Reset/local snapshot over server truth").
- **Borrow, never copy, the entity table.** Fellowship member rows must
read live names/vitals from `RuntimeEntityObjectLifetime`'s canonical
`ClientObjectTable` the way `RuntimeInventoryState` does
(`GameRuntime.cs:192-193`) — a second copy of member names is exactly the
"two writable copies" divergence OP9 deleted
(`project_settings_options_digest.md`, AP-196 row).
- **Typed borrowed views, not events, for the panels.**
`IRuntimeSocialView` (`GameRuntimeGameplayViews.cs:105-110`) is the exact
template: a `Snapshot` record struct plus `TryGet*` accessors, no
collection allocation. Add `IRuntimeFellowshipView` /
`IRuntimeAllegianceView` to `GameRuntimeGameplayViews.cs` and hang them
off `IGameRuntimeView` (`GameRuntimeViews.cs:243-272`) so headless bots
can assert roster state without touching presentation.
- **Generation-gated commands.** Add
`IRuntimeFellowshipCommands` / `IRuntimeAllegianceCommands` to
`GameRuntimeCommands.cs` alongside `IRuntimeSocialCommands` (`:291-300`),
register them on `IGameRuntimeCommands` (`:302-325`), and add two
`RuntimeCommandDomain` ordinals (`GameRuntimeEvents.cs:11-24` — currently
`Magic = 10`, so `Fellowship = 11`, `Allegiance = 12`). Both host
adapters must implement: `DirectGameRuntimeCommandAdapter`
(`src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs:20-31`
interface list, `:718-750` friend-command template) and
`CurrentGameRuntimeCommandAdapter`
(`src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs:690,703`
it publishes onto the App command bus instead of touching the session).
- **Ordered deltas only if a bot needs them.** The J-pattern's event hub
(`GameRuntimeEventHub`, constructed at `GameRuntime.cs:304-307` from
entity/communication/action owners) feeds `IRuntimeEventObserver`
(`GameRuntimeEvents.cs:98-115`). A `OnFellowship(in
RuntimeFellowshipDelta)` member is a **breaking change to every
implementer** (5 bot policies in
`src/AcDream.Headless/Policies/HeadlessBotPolicy.cs`, the trace recorder
at `GameRuntimeEvents.cs:148-311`, App observers). Prefer **not** adding
an observer member unless the bot gate (§6) genuinely needs push
semantics; a `Snapshot.Revision` poll off the borrowed view is the
cheaper contract and matches `IRuntimeSocialView`.
### 1.4 The one thing that *should* live in Communication
Fellowship/allegiance **system message text** (recruits, quits, logon
notices) is interface text, not fellowship state — it goes through
`RuntimeCommunicationState.AddText`
(`RuntimeCommunicationState.cs:210-239`). See §7.
---
## 2. Inbound wire routing — the pattern a new event family must follow
### 2.1 There is exactly ONE registration site, and it is in Runtime
`GameEventWiring.WireAll`
(`src/AcDream.Core.Net/GameEventWiring.cs:36-99`) is called from exactly
one place in `src/`: `LiveSessionEventRouter.Attach`
(`src/AcDream.Runtime/Session/LiveSessionEventRouter.cs:179-231`). A
whole-`src` grep for `WireAll` returns those two hits only. **This is the
K-slice unification: both hosts share the router.** So a new event family
is wired once and both hosts get it — but only if the *sink* is reachable
from both, which is why the sink must be a Runtime owner (§1) and not an
App object.
### 2.2 The mechanical shape
`GameEventWiring` is a static method taking one positional core
(`dispatcher, items, combat, spellbook, chat`) plus ~20 optional
delegate/state "holes" (`GameEventWiring.cs:41-99`). Core.Net **cannot
reference `AcDream.Runtime`** — the CH2 comment at
`GameEventWiring.cs:90-98` states this explicitly ("Core.Net cannot
reference AcDream.Runtime directly, so this is a delegate hole exactly
like every other Runtime-owned sink above"). Two legal shapes:
- **State object** (like `friends`, `squelch`, `itemMana`, `vendor`,
`externalContainers`): the type lives in `AcDream.Core.*`, Runtime owns
the instance, Core.Net writes into it. `FriendsState`/`SquelchState` live
in `AcDream.Core.Social`; `AllegianceTree` already lives in
`AcDream.Core.Allegiance` — **so the allegiance tree can be passed
directly as a state parameter**, exactly like `friends`.
- **Delegate hole** (like `onCharacterOptions`, `onInterfaceText`,
`onUseDone`): a `Action<...>` the router closes over.
Registration itself: `registrar.Register(GameEventType.X, e => {...})`
against the owned registrar (`GameEventWiring.cs:923-943`), which wraps
every handler in the `accepting` gate so a retiring generation cannot
receive. Do not register outside `WireAll` — the `RegistrationBuildScope`
(`:945-961`) guarantees all-or-nothing ownership.
### 2.3 The 11 events FA must register
From `GameEventType.cs`:
| Id | Name | Line |
|---|---|---|
| `0x0003` | `AllegianceUpdateAborted` | `:16` |
| `0x0020` | `AllegianceUpdate` | `:19` |
| `0x00A3` | `FellowshipQuit` | `:29` |
| `0x00A4` | `FellowshipDismiss` | `:30` |
| `0x01C8` | `AllegianceUpdateDone` | `:55` |
| `0x01C9` | `FellowshipFellowUpdateDone` | `:56` |
| `0x01CA` | `FellowshipFellowStatsDone` | `:57` |
| `0x027A` | `AllegianceLoginNotification` | `:84` |
| `0x027C` | `AllegianceInfoResponse` | `:85`**already registered** (`GameEventWiring.cs:192-198`, TS-70 fix) |
| `0x02BE` | `FellowshipFullUpdate` | `:100` |
| `0x02BF` | `FellowshipDisband` | `:101` |
| `0x02C0` | `FellowshipUpdateFellow` | `:102` |
Note `0x027C` is already consumed for `@allegiance info` chat output
(`GameEventWiring.cs:192-198``ClientCommandResponses.ParseAllegianceInfoResponse`).
FA must **not** register a second handler for it; either extend the
existing lambda or add a second `registrar.Register` for the same type
(the dispatcher supports multiple owned handlers per type — see
`OwnedGameEventRegistrar.Register`, `:929-940`) and accept that both fire.
Decide this in the plan, explicitly.
### 2.4 Bindings-record plumbing (both hosts)
`LiveSessionEventRouter`'s five binding records are the host-facing
contract (`LiveSessionEventRouter.cs:15-82`). FA adds either a field to
`LiveSocialSessionBindings` (`:72-82`) or a **new**
`LiveFellowshipSessionBindings`/`LiveAllegianceSessionBindings` record.
Prefer extending `LiveSocialSessionBindings` with **trailing optional
parameters** — the established compatibility convention, stated at
`:42-44` ("Trailing/optional so every existing positional caller
(Headless) compiles unchanged").
Both construction sites must be updated (**blast-radius rule: the no-window
host is a first-class consumer**):
- Graphical: `src/AcDream.App/Net/LiveSessionRuntimeFactory.cs:248-261`
(`new LiveSocialSessionBindings(_domain.Communication.Chat, ...)`).
- Headless: `src/AcDream.Headless/Hosting/HeadlessSessionHost.cs:780-785`
(`new LiveSocialSessionBindings(Runtime.CommunicationOwner.Chat, ...)`).
Each host then wraps the router: `GraphicalSessionEventRoute`
(`src/AcDream.App/Net/GraphicalSessionEventRoute.cs`) and
`HeadlessSessionEventRoute`
(`src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs:14-53`). Both
delegate `Attach`/`Dispose` to the shared router, so **no FA change is
needed in the wrappers** provided the sink is Runtime-owned.
### 2.5 Parsers
New parsers go in `src/AcDream.Core.Net/Messages/GameEvents.cs` (609
lines, static `Parse*` returning nullable structs — the file currently has
zero fellowship parsers; the only `Allegiance` token is
`AllegianceMonarchId` at `:589`, a `PlayerDescription` field). Follow the
`ParseChannelBroadcast` / `ParseTell` shape: `ReadOnlySpan<byte>` in,
`Nullable<struct>` out, `null` on malformed.
---
## 3. Outbound actions — the C→S builder pattern
### 3.1 `SocialActions` IS the right file for fellowship
`src/AcDream.Core.Net/Messages/SocialActions.cs` already declares the five
fellowship opcodes as constants (`:36-41`) and ships all five builders
(`:123-168`). It also — awkwardly — hosts `CharacterOptionId`
(`:362-417`) and the `0x0005`/`0x01A1` option builders. Keep fellowship
here; do **not** create a `FellowshipActions.cs` that splits the family.
Allegiance already has its own file
(`src/AcDream.Core.Net/Messages/AllegianceRequests.cs:27-56`, Swear
`0x001D` / Break `0x001E`). Extend that file for the remaining allegiance
GameActions (#360's boot/ban/officer/title/motd/name/lock/house set) rather
than moving them into `SocialActions`.
### 3.2 The missing link: `WorldSession.Send*` wrappers
Every outbound family reaches the wire through a
`WorldSession.Send<Verb>()` method that allocates the sequence and calls
`SendGameAction` — e.g.
`WorldSession.SendSetSingleCharacterOption`
(`src/AcDream.Core.Net/WorldSession.cs:2202-2206`) and
`SendAllegianceInfoRequest` (`:2312-2316`). **No such wrapper exists for
any fellowship or swear/break builder.** FA must add:
```
SendFellowshipCreate / Quit / Dismiss / Recruit / Update
SendAllegianceSwear / SendAllegianceBreak
```
then reach them from `DirectGameRuntimeCommandAdapter` (Runtime, direct)
and via a `*RuntimeCmd` record + `LiveSessionCommandRouter` registration
for the App bus path
(`src/AcDream.App/Net/LiveSessionCommandRouter.cs:74-81` declares the
records, `:173-186` registers the handlers).
### 3.3 Golden-vector test conventions
`tests/AcDream.Core.Net.Tests/Messages/` is the home
(`SocialActionsTests.cs`, `AllegianceRequestsTests.cs` already exist).
Two tiers, both used:
- **Field-probe tests** — read individual offsets back
(`SocialActionsTests.cs:53-104` covers all five fellowship builders;
`AllegianceRequestsTests.cs:10-34` covers swear/break). Cheap, and what
the existing FA builders have.
- **Hand-computed golden byte vector** — the OP1 convention, and the
stronger one. `SocialActionsTests.cs:136-142` states the rule verbatim:
*"The golden vector below is HAND-COMPUTED, field by field, from that
layout — not generated by calling the builder under test. The CH3 builder
(deleted 2026-08-09) died of ten green tests pinning a wrong shape."*
Example at `:144-175`. **Any FA builder whose layout lane A/B/C had to
derive (rather than copy from an existing green test) must get a
hand-computed golden vector.** `AceWireWriter.cs` in the same directory
is the test-only ACE-mirror writer for inbound goldens.
`git`-visible caution: `BuildFellowshipCreate`'s padding math
(`SocialActions.cs:128-137`) computes `pad` but never uses it in the
`body` length beyond `new byte[12 + name.Length + boolBlock + pad]` — the
existing test (`SocialActionsTests.cs:53-68`) asserts bool offsets 20/21
for a 4-char name, i.e. it pins the current shape but **not against a
retail/ACE reference**. Lane A/B should confirm the trailing-pad rule
before FA relies on it.
---
## 4. The AD-78-dimmed rows that gain consumers
### 4.1 Exact inventory — the six Grouping-group rows
All six live in `CharacterOptionsPageController.Groups[2]`
(`src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs:193-201`),
header `ID_CharacterOption_Grouping_Section`, and all six are `StoreOnly`:
| `CharacterOptionId` | Value | Word/mask | AutoSave | ClientDefault | Controller line | Table line |
|---|---|---|---|---|---|---|
| `IgnoreAllegianceRequests` | `0x01` | Options1 `0x00000004` | yes | false | `:195` | `CharacterOptionTable.cs:112` |
| `IgnoreFellowshipRequests` | `0x02` | Options1 `0x00000008` | yes | **true** | `:196` | `:113` |
| `DisplayAllegianceLogonNotifications` | `0x18` | Options1 `0x08000000` | no | false | `:197` | `:135` |
| `FellowshipShareXP` | `0x0F` | Options1 `0x00040000` | yes | **true** | `:198` | `:126` |
| `FellowshipShareLoot` | `0x11` | Options1 `0x00100000` | yes | false | `:199` | `:128` |
| `FellowshipAutoAcceptRequests` | `0x12` | Options1 `0x20000000` | yes | false | `:200` | `:129` |
A seventh row is FA-adjacent but Chat-group:
`ListenToAllegianceChat` (`0x1B`, Options1 `0x40000000`) at
`CharacterOptionsPageController.cs:229`**already `Live`**, consumed by
`TurbineChatMembershipGate.cs:107-110`. Do not touch it.
### 4.2 The tests that pin the dimmed list — un-dim goes through these
`tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs`:
- `ExpectedStoreOnlyIds` literal set — the 35-id hand-transcribed
authority (`:710-753`). The six FA ids are at `:728-733` under the
comment `// Group 3 (Grouping) — all 6`.
- **`StoreOnlyRows_MatchTheDerivationTableExactly`** (`:755-766`) —
asserts set equality with `Groups`, plus `Assert.Equal(35, ...)` and
`Assert.Equal(15, 50 - ...)`. **Un-dimming N rows requires editing three
numbers here**: remove from the literal set, and change `35``35-N` and
`15``15+N`.
- **`Bind_AppliesDimmedCaptionColor_ForStoreOnlyRows_AndWhiteForLiveRows`**
(`:768-819`) — builds the real fixture tree and compares each checkbox's
`LabelColor` against `UiRenderContext.StoreOnlyCaptionColor` vs
`Vector4.One`, row-index-aligned. Derived from `Groups`, so it follows
automatically once `Groups` flips.
The class doc states the contract explicitly at `:700-709`: *"Wiring a
future consumer for any of these means removing it from this literal set
AND flipping its `Groups` table entry from `StoreOnly` to `Live`
consciously — leaving either one stale fails this test."*
The dim mechanism itself is `CharacterOptionsPageController.cs:418-423`
(`checkbox.LabelColor = spec.StoreOnly ? UiRenderContext.StoreOnlyCaptionColor
: Vector4.One`). Rows stay fully interactive either way — only the caption
dims.
### 4.3 Which rows *should* un-dim, and which should not
The plan must decide per-row, but the code evidence says:
- `IgnoreFellowshipRequests` / `FellowshipAutoAcceptRequests` — **already
have a genuine coded client-side consumer**: retail's `OnChanged` mutual
exclusion (MF-2), ported at
`src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs:818-834` with the
full rationale in the `<remarks>` at `:790-805`. The OP4 author flagged
this as a known ambiguity and deliberately left them dimmed
(`CharacterOptionsPageController.cs:147-154`: *"have a genuine coded
mutual-exclusion side effect … kept dimmed per the doc's explicit Group A
listing … a future un-dim is a one-line judgment call, not a
re-investigation."*). **FA gives them a real second consumer** (the
incoming-fellowship-request path), so these two are the clearest un-dim
candidates.
- `DisplayAllegianceLogonNotifications` — un-dims only if FA implements the
`AllegianceLoginNotification 0x027A` handler AND gates its chat output on
the bit. That gate is the consumer.
- `FellowshipShareXP` / `FellowshipShareLoot` — these are **server-side**
policy (ACE decides the split). They un-dim only if FA reads them to
drive the create-fellowship dialog's initial checkbox state
(`BuildFellowshipCreate(seq, name, openness, shareXp)`,
`SocialActions.cs:123-138`, takes `shareXp` as a parameter — that IS a
client-side read). `FellowshipShareXP` is additionally tagged Group D
(deferred) in the derivation table (`:137`).
- `IgnoreAllegianceRequests` — un-dims if FA gates the incoming-swear
confirmation on it.
**MF-2 is binding on any un-dim.** `HeadlessConfigurationLoader.cs:252-274`
already rejects a bot config declaring both `IgnoreFellowshipRequests` and
`FellowshipAutoAcceptRequests` true, because retail's recursive clear makes
that unsatisfiable. Any FA UI that surfaces both toggles inherits the same
constraint — the panel must reflect the auto-clear, since `TrySetOption`
performs it locally before the send (`RuntimeCharacterState.cs:812-841`).
---
## 5. Panel mounting, dialogs, strings
### 5.1 Cataloging a new `gmPanelUI` panel
`src/AcDream.App/UI/RetailPanelCatalog.cs` holds the DAT property
`0x10000029` panel-id constants (`:10-18`) plus two tables: `Mounted`
(`:31-43`) and `Toolbar` (`:45-51`). FA adds `Allegiance` and `Fellowship`
constants **byte-verified from the authored toolbar/panel property**, the
way OP3 verified `Options = 10u` (`:20-29` documents that exact method:
read property `0x10000029` off the toolbar button in the committed fixture
AND cross-check the decompiled slot-key table). Do not guess the ids.
Window names go in `src/AcDream.App/UI/WindowNames.cs:5-32` (one `const
string` each) — the file's own doc says it exists "so the mount, the window
registry, and the toggle keybind all agree on one literal."
### 5.2 Mount method template
`RetailUiRuntime.MountCharacter`
(`src/AcDream.App/UI/RetailUiRuntime.cs:2663-2706`) is the cleanest
template — 44 lines, five steps:
1. `Import(0x2100002Eu)``ImportedLayout?`, log-and-return on null
(`:2665-2670`);
2. build/subscribe the data provider (`:2671-2674`);
3. `<X>Controller.Bind(layout, ...)` (`:2675-2682`);
4. `RetailWindowFrame.Mount(Host.Root, layout.Root, resolveSprite, new
RetailWindowFrame.Options { WindowName = ..., Chrome = NineSlice, ...,
Visible = false, ... })` → `RetailWindowHandle` (`:2683-2700`);
5. `_panelUi.RegisterMainPanel(RetailPanelCatalog.X, WindowNames.X,
handle)` (`:2701-2704`).
Then add the `Mount<X>()` call to `Initialize()`
(`RetailUiRuntime.cs:385-413` — the ordered list; note `MountDialogFactory`
at `:404` runs **before** `MountCharacter` at `:405`, and
`MountCharacter`'s own `HandleCharacterRaise` throws if that ordering is
violated, `:2720-2723`). Any FA panel that raises a confirmation must be
mounted after `MountDialogFactory`.
`RegisterMainPanel` (`src/AcDream.App/UI/Layout/RetailPanelUiController.cs:47-65`)
enforces name==handle.Name and shares one parent geometry across all
registered main panels — that is retail's `gmPanelUI` single-active-child
behavior, so FA panels get "opening Fellowship closes Allegiance" for free.
`RetailWindowManager.Register`
(`src/AcDream.App/UI/RetailWindowManager.cs:57-105`) is called by
`RetailWindowFrame.Mount` — FA does not call it directly. It requires the
outer frame be a **direct `UiRoot` child** (`:70-72`) and fires
`WindowRegistered` (`:44`) so the opacity controller picks the window up
automatically.
Bindings: add a `FellowshipRuntimeBindings` / `AllegianceRuntimeBindings`
group to the `RetailUiRuntimeBindings` record
(`RetailUiRuntime.cs:300-325`) — one record per subsystem is the
established grouping.
Keybinds: wire `InputAction.ToggleAllegiancePanel` /
`.ToggleFellowshipPanel` into `RetailUiRuntime.HandleInputAction`
(`:548-560` — the `ToggleSpellbookPanel` case at `:552-556` is the
one-liner template). The enum members, retail ActionMap ids, and F3/F4
defaults already exist (§0) — **only the handler is missing.**
Persistence: if the panel should keep its own visibility across sessions,
it must NOT be listed in `stateManagedVisibilityWindows`
(`RetailUiRuntime.cs:422-428`).
### 5.3 The confirmation seam — recruit / swear
`RetailDialogFactory`
(`src/AcDream.App/UI/Layout/RetailDialogFactory.cs:8-38`) is the port of
retail's `DialogFactory @0x004773C0`. Two entry points:
- `MakeConfirmation(message, callback, queueKey, priority)`
(`:125-136`) — the simple seam.
- `MakeDialog(RetailDialogData, callback)` (`:74-123`) — full control over
queue key, priority preemption, and `RetailDialogProperty` flags.
**Server-driven** confirmations (which is what an incoming
recruit/swear request is) already have a semantic owner:
`GameplayConfirmationController`
(`src/AcDream.App/UI/GameplayConfirmationController.cs:12-102`). Its
`HandleRequest` comment names FA explicitly at `:36-40`:
> *"Types 1 and 4 have allegiance/fellowship semantic owners but use the
> same response tuple, so this controller retains that tuple until those
> panels exist."*
So the FA plan should decide whether types 1/4 stay on the generic
controller (retaining the `(serverType, serverContext)` tuple, response
sent at `:87-101`) or gain their own owners. Note the single-slot guard at
`:44-47`: a second gameplay confirmation is **refused** while one is open —
mirror retail. Also mirror the DO-NOT-RETRY row from
`project_chat_digest.md`: `OpenNextDialog` must return early when the queue
key is already occupied (`RetailDialogFactory.cs:323-334`), because a
two-stage confirmation (the FA recruit-then-confirm flow is a candidate)
can synchronously reopen under the same key.
### 5.4 String resolution
`DatStringResolver`
(`src/AcDream.App/UI/Layout/DatStringResolver.cs:16-83`). Two rules:
- `ComputeHash(string)` (`:68-82`) is the exact retail ELF-style hash from
`compute_str_hash @0x00413110`. Labels are resolved as
`resolveString(tableId, ComputeHash("ID_..."))` — never hard-coded
English. Template: `CharacterOptionsPageController.cs:409-416`, which on
a failed lookup logs and leaves the caption **null**, explicitly "rather
than invented English."
- Table ids are family-specific. `0x23000003` is the options family
(`CharacterOptionsPageController.cs:84`); `0x2300000D` is the chat
text-filter family; `0x23000004` is keyboard refusal
(`project_settings_options_digest.md` DO-NOT-RETRY row). **FA must
dat-verify which table holds `ID_Fellowship_*` / `ID_Allegiance_*` before
assuming `0x23000003`.**
- Resolution is **per-`Build`, not per-import**: the resolver is passed
into `LayoutImporter.Build` as its fifth argument
(`RetailUiRuntime.cs:2041-2046`) AND separately into each page/row
controller (`:2100`). Omitting it from either produces silent blanks —
that was half of #375.
### 5.5 DO-NOT-RETRY rows transcribed from `project_settings_options_digest.md`
These are the mount-a-new-panel traps Campaign OP paid for. All apply
verbatim to an FA panel:
| Trap | Truth |
|---|---|
| Lazily creating a fill-anchored child at 0×0 | **#372.** `ComputeAnchoredRect` captures the degenerate baseline **permanently** → whole-page blank. Seed the viewport size to the parent extent at creation (`UiTemplateListBox`). *Fixture conformance tests stayed GREEN through this* — only mount+activate+switch+assert-drawn tests catch the class. |
| Assuming an imported tab host is active | **#375 residual.** A tab control must be explicitly activated (`controller.ActivateTabs()`), and the FA panel's own tab control if it has one. Commit `a8ce010d`. |
| Parked template prototypes / missing string resolver | **#375.** A template list whose prototypes were never parented, plus a `Build` call missing its string resolver, both render as blank rows. Commit `8bd7e3b8`. |
| Flat `layout.FindElement` for shared element ids | Apply/Reset/Defaults share element ids **across pages**. Always per-page scoped `FindDescendant`, never a flat layout lookup. (`CharacterOptionsPageControllerTests.cs:389-394` documents the collision.) |
| Open popups losing pointer routing | **#374.** An open dropdown/popup gets **first claim** on pointer routing in `UiRoot`. Commit `355c86a6`. |
| Rows straddling the viewport edge vanishing | **#371.** A viewport must `ClipsChildren` + intersection-cull, not whole-row-cull. Commit `a59e077a`. |
| Trusting a BN literal-0 operand for a string id / global | Bit Campaign OP **four times**. Byte-verify from the PDB-paired `acclient.exe` (`check_exe_pdb.py` MATCH) before believing a 0. |
| Re-adding a client copy of a server bit | AP-196 is closed. Server bits live ONLY in `RuntimeCharacterOptionsState`; client-only prefs in the Config-tab store. |
---
## 6. Headless bot seam for counterpart characters
### 6.1 What K2 gives you today
`IHeadlessBotPolicy`
(`src/AcDream.Headless/Policies/HeadlessBotPolicy.cs:7-12`):
```csharp
internal interface IHeadlessBotPolicy : IRuntimeEventObserver, IDisposable
{
bool IsComplete { get; }
void Tick(IGameRuntimeView view, IGameRuntimeCommands commands);
}
```
Policies are string-selected by config id in
`HeadlessBotPolicyFactory.Create` (`:16-27`); five exist today: `idle`,
`lifecycle-smoke`, `observer-movement`, `portal-route-smoke`,
`jump-probe`. `LifecycleSmokeHeadlessBotPolicy.Tick` (`:92-142`) is the
canonical stage-machine template — every command call is
generation-gated (`commands.Chat.Execute(view.Generation, ...)`, `:103`)
and its result checked via `Require(...)`.
Config shape: `HeadlessSessionDescriptor`
(`src/AcDream.Headless/Configuration/HeadlessConfiguration.cs:38-75`) —
`id`, `endpoint`, `account`, `character`, `policy`, `credential`, optional
`characterOptions`. Note the MF-1 comment at `:32-37`: it is a **record**
so `with` expressions in the direct-CLI path cannot silently drop a
property. Multiple sessions in one process is already the shape
(`Sessions` list, `:13`) and K3/K4 gated 1/5/10/30-session isolation.
Declared-option validation: `AllowedCharacterOptions`
(`HeadlessConfigurationLoader.cs:27-57`) — **all six FA option ids are
already tier-1 allow-listed** (`:30,31,34,36,37,38`), plus
`ListenToAllegianceChat` (`:46`). The MF-2 contradiction check is at
`:252-274`.
Option seeding engine: `HeadlessCharacterOptionsSeeder`
(`src/AcDream.Headless/Hosting/HeadlessCharacterOptionsSeeder.cs:58-136`)
— diff-and-send through the SAME `IRuntimeCharacterCommands` seam, gated
on a two-precondition latch (`LoginComplete` sent AND `HasServerSeed`).
Existing bot-vs-ACE gate recipe: `docs/research/2026-08-11-campaign-op-test-script.md:766-895`
(§OP7). Its structure — declare a config, run `acdream-headless run
--config X.json`, inspect ACE-side persisted state, then re-run for
idempotence — is exactly reusable for FA.
### 6.2 What a bot-recruits-bot fellowship gate needs added
**Config / infrastructure:**
1. **A second ACE account.** The OP7 recipe uses one session
(`testaccount` / `+Acdream`). A fellowship gate needs two logged-in
characters simultaneously. K3 already gated multi-session isolation, and
`HeadlessConfiguration.Sessions` is a list — but the **credential**
model is per-session (`HeadlessCredentialReference`, `:107-114`), so two
distinct env-var references (e.g. `FA_BOT_A_PASSWORD`,
`FA_BOT_B_PASSWORD`) suffice **if** ACE has a second account with a
second character. *This is the one genuinely external prerequisite —
flag it for the user.* CLAUDE.md documents only `testaccount` /
`+Acdream`.
2. **The two bots must be co-located.** Recruit requires the target be
within range and selectable. Either both characters start at the same
spawn, or the policy drives one to the other (the
`ObserverMovementHeadlessBotPolicy` / `PortalRouteSmokeHeadlessBotPolicy`
movement machinery already exists, `HeadlessBotPolicy.cs:204-306`,
`:353-593`).
3. **A new policy id**, e.g. `fellowship-smoke`, registered in
`HeadlessBotPolicyFactory.Create` (`:16-27`), with a role discriminator
(leader vs recruit). The policy interface has no per-session parameter
today beyond the id string — either encode the role in the id
(`fellowship-smoke-leader` / `fellowship-smoke-member`) or extend
`HeadlessBotPolicyDescriptor` (`HeadlessConfiguration.cs:93-97`, which
currently carries only `Id`).
**Runtime commands the policy needs (new, §1.3/§3.2):**
| Command | Underlying builder |
|---|---|
| `commands.Fellowship.Create(gen, name, open, shareXp)` | `SocialActions.BuildFellowshipCreate` |
| `commands.Fellowship.Recruit(gen, targetGuid)` | `BuildFellowshipRecruit` |
| `commands.Fellowship.Quit(gen, disband)` | `BuildFellowshipQuit` |
| `commands.Fellowship.Dismiss(gen, targetGuid)` | `BuildFellowshipDismiss` |
| `commands.Fellowship.SetOpen(gen, open)` | `BuildFellowshipUpdate` |
| `commands.Allegiance.Swear(gen, patronGuid)` | `AllegianceRequests.BuildSwear` |
| `commands.Allegiance.Break(gen, targetGuid)` | `BuildBreak` |
Plus target acquisition: the recruit target is another player's server
guid. `RuntimeHostileTargetQuery.FindClosest`
(`src/AcDream.Runtime/Gameplay/RuntimeHostileTargetQuery.cs`, used at
`HeadlessGameplayOperations.cs:143`) finds hostiles only — FA needs a
**friendly/player** nearest-object query, or the policy reads
`view.InventoryState`/entity deltas for a known character name. The bot
already receives `OnEntity` deltas
(`GameRuntimeEvents.cs:104`), so name-matching off `RuntimeEntitySnapshot`
is the low-friction route.
**Views the policy needs to assert on:**
- `view.Fellowship.Snapshot` — at minimum `{ Revision, IsInFellowship,
Name, MemberCount, LeaderGuid, IsOpen }`, plus
`TryGetMember(guid, out RuntimeFellowMemberSnapshot)`. Modeled on
`IRuntimeSocialView` (`GameRuntimeGameplayViews.cs:105-110`).
- `view.Allegiance.Snapshot` — `{ Revision, MonarchGuid, PatronGuid,
VassalCount, Rank }`, plus `TryGetNode`.
**Gate assertions (what "passed" means):**
1. Bot A creates a fellowship → A's `Fellowship.Snapshot.IsInFellowship`
true, `MemberCount == 1`.
2. A recruits B → B's own snapshot flips (proving the `0x02BE`
`FellowshipFullUpdate` inbound path reached B's Runtime owner, not just
A's local echo). **This is the assertion that only a two-bot gate can
make.**
3. `FellowshipAutoAcceptRequests` declared true on B via
`characterOptions` → recruit succeeds without a confirmation; declared
false → ACE sends `CharacterConfirmationRequest` type 4 and the bot must
answer it (needs a bot-visible confirmation command, or the policy
declares auto-accept to avoid it in v1 — **recommend v1 declares
auto-accept**).
4. `IgnoreFellowshipRequests` true on B → recruit refused; asserts the
server honors the bit.
5. B quits → A's `MemberCount` drops (proving `0x00A3`/`0x02C0` inbound).
6. A disbands → both snapshots clear (proving `0x02BF`).
7. Graceful SIGINT → both Runtime roots converge, FA owners' ownership
snapshots `IsConverged` (the K4 terminal-ownership check,
`src/AcDream.Headless/Hosting/HeadlessStaticStateAudit.cs`).
Allegiance's swear/break is harder to gate: ACE enforces rank/vassal-cap
rules and swearing is semi-permanent on a shared test character. Recommend
the FA plan gate **fellowship with two bots** and gate **allegiance
manually via the graphical client** against a throwaway pair, rather than
burning `+Acdream`'s allegiance state.
---
## 7. Chat / interface-text seam
### 7.1 The chokepoint
`RuntimeCommunicationState.AddText(string text, RetailLogTextType type,
uint windowId = 0)`
(`src/AcDream.Runtime/Gameplay/RuntimeCommunicationState.cs:210-239`) is
the one seam, the port of `ClientSystem::AddTextToScroll @0x00563C50`.
Behavior: `Trim()` both ends (`:227`, and the comment at `:214-226`
explains why there is deliberately **no** empty-string guard);
`ClientLocal 0x1A` → SpewBox only (`:229-233`); everything else →
`Chat.OnSystemMessage(text, (uint)type)` (`:238`).
Reached from Core.Net through the `onInterfaceText` delegate hole
(`GameEventWiring.cs:90-98`), supplied by
`LiveSocialSessionBindings.AddText`
(`LiveSessionEventRouter.cs:77-82`) — bound in **both** hosts
(`LiveSessionRuntimeFactory.cs:261`, `HeadlessSessionHost.cs:785`).
### 7.2 Which `RetailLogTextType` FA messages use
`src/AcDream.Core/Chat/RetailLogTextType.cs`:
- `Allegiance = 0x12` (`:44`)
- `Fellowship = 0x13` (`:45`)
Both already have retail-exact RGBA in `RetailChatColorTable`
(`AcDream.UI.Abstractions`), conformance-pinned to
`ChatInterface::BuildChatColorLookupTable @0x4f31c0`
(`project_chat_digest.md`, "Current truth"). **No color work needed —
just pass the right type.**
Rules carried from the chat digest:
- **Do not add chat-color configurability.** Retail hard-codes every
`LogTextType`; only per-window type *filters* are user-settable.
- **`ClientLocal 0x1A` is SpewBox-only** and must never reach the
scrolling chat window. Local FA refusals ("You are ignoring fellowship
requests.") are the `0x1A` class; server-sent roster notices are
`0x13`/`0x12`.
- **Refusal text comes from `WeenieErrorMessages`** (344 rows, binary-swept
`src/AcDream.Core/Chat/WeenieErrorMessages.cs`), not from invented
English. FA refusals arriving as `WeenieError`/`WeenieErrorWithString`
already route correctly through the existing `GameEventWiring`
registration; FA only needs to confirm the specific ids
(`YouAreNotInAFellowship`, `FellowshipIsFull`, etc.) exist in the table.
- Register row **AP-183 / #363** notes ~10 sites typed `0x00` where retail
types `0x1A` — do not add more.
### 7.3 Allegiance logon notifications
`AllegianceLoginNotification 0x027A` is the one FA message whose *display*
is gated by a character option:
`DisplayAllegianceLogonNotifications` (`0x18`, Options1 `0x08000000`,
non-auto-save — `CharacterOptionTable.cs:135`). The gate must read the
Runtime option bit, exactly like `TurbineChatMembershipGate.cs:105-136`
does for the six `ListenTo*Chat` bits. **That gate is the consumer that
un-dims the row** (§4.3).
Note the chat-digest distinction: `Hear*Chat` bits are channel
*membership* (retail refuses to even send). `DisplayAllegianceLogonNotifications`
is different — it is a genuine *receive-side display* filter, so the
inbound handler must swallow the notice locally when the bit is off. Lane
A/B should confirm this against retail rather than inferring it from ACE.
---
## 8. Seam map (summary table)
| Subsystem | Where new code goes | Pattern to follow | Precedent citation |
|---|---|---|---|
| Fellowship state owner | `src/AcDream.Runtime/Gameplay/RuntimeFellowshipState.cs` (new) | J4 owner: `IDisposable` + `ResetSession()` + `CaptureOwnership()`; borrow entity table | `RuntimeInventoryState` ctor `GameRuntime.cs:192-193`; snapshot shape `RuntimeCharacterState.cs:9-51` |
| Allegiance state owner | `src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs` (new), wrapping existing `AcDream.Core.Allegiance.AllegianceTree` | Same; **survives reconnect** — use a `HasServerSeed`-style latch | `RuntimeCharacterOptionsState` seed latch; `AllegianceTree.cs:56-162` |
| Owner registration | `GameRuntime.cs` | ctor + `construction.Own` + fault point + `*Owner` prop + view prop + `CaptureOwnership` + shutdown step | `GameRuntime.cs:200-214`, `:419-429`, `:463-470`, `:545-567`, `:703-752` |
| Session reset | `RuntimeGenerationReset.cs` | new `RuntimeGenerationResetStage` ordinal + `Drain` case; **fellowship clears, allegiance re-seeds** | `:21-44`, `:236-349`; shared-stage example `:248-261` |
| Ownership rollup | `RuntimeGameplayOwnership.cs` | add to record + `Capture(...)` signature + `IsConverged` | `:8-21`, `:23-44` |
| Typed borrowed views | `GameRuntimeGameplayViews.cs` + `GameRuntimeViews.cs:243-272` | `Snapshot` record struct + `TryGet*`; no allocation | `IRuntimeSocialView` `GameRuntimeGameplayViews.cs:90-110` |
| Generation-gated commands | `GameRuntimeCommands.cs` + BOTH adapters | new interface + `IGameRuntimeCommands` member + `RuntimeCommandDomain` ordinal | `IRuntimeSocialCommands` `:291-300`; `DirectGameRuntimeCommandAdapter.cs:718-750`; `CurrentGameRuntimeCommandAdapter.cs:690` |
| Inbound parsers | `src/AcDream.Core.Net/Messages/GameEvents.cs` | static `Parse*` → nullable struct, `null` on malformed | `ParseChannelBroadcast`, `ParseTell` |
| Inbound registration | `src/AcDream.Core.Net/GameEventWiring.cs` (inside `WireAll`) | `registrar.Register(type, e => ...)`; state-object OR delegate hole | `friends`/`squelch` params `:80-81`; `AllegianceInfoResponse` `:192-198` |
| Host bindings plumbing | `LiveSessionEventRouter.cs:72-82` + **both** construction sites | trailing optional params | `LiveSessionRuntimeFactory.cs:256-261`; `HeadlessSessionHost.cs:780-785` |
| Outbound builders (fellowship) | `SocialActions.cs` (already there, `:123-168`) | keep the family together | — |
| Outbound builders (allegiance) | `AllegianceRequests.cs` (extend, `:27-56`) | keep the family together | — |
| Outbound send wrappers | `src/AcDream.Core.Net/WorldSession.cs` (**MISSING — must add**) | `NextGameActionSequence()` + `SendGameAction(builder(...))` | `SendSetSingleCharacterOption` `:2202-2206` |
| App bus path | `LiveSessionCommandRouter.cs` | `*RuntimeCmd` record + `commands.Register<T>(... SendIfActive ...)` | `:74-81`, `:173-186` |
| Wire tests | `tests/AcDream.Core.Net.Tests/Messages/` | field probes + **hand-computed** golden vector for any derived layout | `SocialActionsTests.cs:136-175`; `AllegianceRequestsTests.cs:10-34` |
| Option un-dim | `CharacterOptionsPageController.cs:193-201` + tests | flip `StoreOnly``Live` AND edit the literal set + two counts | `CharacterOptionsPageControllerTests.cs:710-766` |
| Panel catalog | `RetailPanelCatalog.cs:10-51` + `WindowNames.cs` | byte-verify the panel id from authored property `0x10000029` | `RetailPanelCatalog.cs:20-29` (the OP3 verification method) |
| Panel mount | `RetailUiRuntime.Mount<X>()` + `Initialize()` list | Import → Bind → `RetailWindowFrame.Mount``RegisterMainPanel` | `MountCharacter` `RetailUiRuntime.cs:2663-2706`; ordering `:385-413` |
| F3/F4 handler | `RetailUiRuntime.HandleInputAction` `:548-560` | one `if (action == ...) { ...; return true; }` | `ToggleSpellbookPanel` case `:552-556` |
| Confirmations | `GameplayConfirmationController` (types 1/4) or a new owner | retain `(serverType, serverContext)`, single-slot guard, respond on close | `GameplayConfirmationController.cs:32-101` (its `:36-40` comment names FA) |
| Dialogs | `RetailDialogFactory.MakeConfirmation` `:125-136` | queue key + priority; `OpenNextDialog` early-return on occupied key | `:323-334` |
| Strings | `DatStringResolver.Resolve(tableId, ComputeHash("ID_..."))` | never invent English; log + leave null | `CharacterOptionsPageController.cs:409-416` |
| System messages | `RuntimeCommunicationState.AddText` | `Allegiance 0x12` / `Fellowship 0x13`; `ClientLocal 0x1A` = SpewBox only | `RuntimeCommunicationState.cs:210-239`; `RetailLogTextType.cs:44-45` |
| Bot gate | `src/AcDream.Headless/Policies/HeadlessBotPolicy.cs` new policy + factory id | stage machine, generation-gated commands, `IsComplete` | `LifecycleSmokeHeadlessBotPolicy` `:84-142`; factory `:16-27` |
| Bot config | `HeadlessConfiguration.cs` / `HeadlessConfigurationLoader.cs` | all 6 FA option names already allow-listed | `:27-57`; MF-2 rejection `:252-274` |
| Bot gate script | `docs/research/2026-08-1X-campaign-fa-test-script.md` | OP7 §recipe shape | `2026-08-11-campaign-op-test-script.md:766-895` |
---
## 9. Open questions for the plan
1. **One owner or two?** This audit recommends two
(`RuntimeFellowshipState`, `RuntimeAllegianceState`) on lifetime
grounds. If the plan prefers one `RuntimeSocialGroupState`, it must
still model two independent reset semantics inside one
`IsConverged`.
2. **Does `IRuntimeEventObserver` gain `OnFellowship`?** Adding a member
breaks all 5 bot policies + the trace recorder + App observers. Decide
push-vs-poll before writing the owner. (Recommend poll via
`Snapshot.Revision`.)
3. **`0x027C AllegianceInfoResponse` already has a handler**
(`GameEventWiring.cs:192-198`, chat output for `@allegiance info`).
Does FA extend that lambda, register a second handler, or leave the
panel to use a different event? The dispatcher permits multiple owned
handlers per type — but two handlers writing to two different owners is
the "two writable copies" smell.
4. **Which of the 6 dimmed rows actually gain consumers in FA v1?** Each
un-dim requires three coordinated edits (§4.2). Under-claiming is safe;
over-claiming makes the conformance test lie.
5. **Second ACE account for the two-bot fellowship gate.** CLAUDE.md
documents only `testaccount`/`+Acdream`. Confirm with the user whether a
second account+character exists, or whether the gate should use two
characters on one account (which ACE will not allow simultaneously).
6. **Allegiance gate strategy.** Swearing is semi-permanent and rank-gated.
Recommend not gating allegiance with bots against `+Acdream`.
7. **`ID_Fellowship_*` / `ID_Allegiance_*` string-table id.** Assume
nothing — dat-verify. The options family is `0x23000003`, but chat
filters live in `0x2300000D` and keyboard refusal in `0x23000004`.
8. **`BuildFellowshipCreate` trailing-pad rule** is currently pinned only
by a self-consistent test (`SocialActionsTests.cs:53-68`), not against a
retail/ACE reference. Lane A/B should confirm before FA sends it live.
9. **`DisplayAllegianceLogonNotifications` semantics**: receive-side
display filter (swallow locally) or membership-style suppression? Unlike
`Hear*Chat` this is almost certainly display-only — but it must be
confirmed against retail, not inferred.
10. **Panel ids for `RetailPanelCatalog`.** Must be byte-verified from the
authored `0x10000029` property, not guessed; the BN literal-0 fold
artifact bit Campaign OP four times.
11. **Does the FA panel participate in `RegisterMainPanel`'s shared
geometry?** That gives retail's single-active-child behavior (opening
Fellowship closes Allegiance) automatically. Confirm that is retail
behavior for F3/F4 before adopting it.
12. **Divergence register rows.** Any FA-introduced approximation needs its
row in `docs/architecture/retail-divergence-register.md` **in the same
commit**; any row FA retires (e.g. #L.4's panel gap) is deleted in the
same commit.
---
## Executive summary
1. **FA is half-scaffolded already:** all 11 event ids, all 7 outbound
builders, the `AllegianceTree` model, both chat `LogTextType`s, the
F3/F4 input actions with retail ActionMap ids, and the MF-2 option
mutual-exclusion exist — but the builders have **zero production
callers**, there are **no `WorldSession.Send*` wrappers**, **no
parsers**, **no inbound registrations**, and **no F3/F4 handler**.
2. **Ownership: two new sibling J-owners under `GameRuntime`**
(`RuntimeFellowshipState`, `RuntimeAllegianceState`), NOT children of
`RuntimeCommunicationState` — the lifetimes differ (fellowship is
session-scoped, allegiance survives reconnect) and J4's own precedent is
one owner per coupled graph. Each costs eight mechanical edits, listed
with line-cited templates in §1.1.
3. **Inbound has exactly one registration site**
(`GameEventWiring.WireAll`, called only from Runtime's
`LiveSessionEventRouter.cs:179`), so both hosts are served by one wiring
— provided the sink is Runtime-owned; both `LiveSocialSessionBindings`
construction sites (App `LiveSessionRuntimeFactory.cs:256`, Headless
`HeadlessSessionHost.cs:780`) must be updated together.
4. **Exactly six dimmed rows are in FA's blast radius**
(`IgnoreAllegianceRequests`, `IgnoreFellowshipRequests`,
`DisplayAllegianceLogonNotifications`, `FellowshipShareXP`,
`FellowshipShareLoot`, `FellowshipAutoAcceptRequests`,
`CharacterOptionsPageController.cs:195-200`); un-dimming any of them
requires three coordinated edits pinned by
`StoreOnlyRows_MatchTheDerivationTableExactly` and
`Bind_AppliesDimmedCaptionColor_ForStoreOnlyRows_AndWhiteForLiveRows`.
5. **A bot-recruits-bot gate needs a second ACE account** (the only
external prerequisite), a new `HeadlessBotPolicy` id with a role
discriminator, a friendly-player target query, and 7 named assertions —
the decisive one being that the *recruited* bot's own snapshot flips,
which only two live sessions can prove.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,921 @@
# 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:<line>`. |
| `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, <tableEnum>)`
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
`<name>` + `" 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(<ID_*>, 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 = <its toggle action>` and
any close button `P0x12 = <the same action>`
(`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** (12 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_<slot>.json`, `allegiance_2100006E_<slot>.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 = <the same action>` | 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 |
---
## 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.)
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`.