diff --git a/docs/research/2026-08-11-fa-acdream-seams.md b/docs/research/2026-08-11-fa-acdream-seams.md new file mode 100644 index 00000000..02008465 --- /dev/null +++ b/docs/research/2026-08-11-fa-acdream-seams.md @@ -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 A–C). 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 `RuntimeState` class with `IDisposable`, `ResetSession()`, + `CaptureOwnership()`; +2. a `RuntimeOwnershipSnapshot` 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 `IRuntimeView` 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` in, +`Nullable` 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()` 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 `` 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. `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()` 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(... 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()` + `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. diff --git a/docs/research/2026-08-11-fa-allegiance-wire.md b/docs/research/2026-08-11-fa-allegiance-wire.md new file mode 100644 index 00000000..280d88ac --- /dev/null +++ b/docs/research/2026-08-11-fa-allegiance-wire.md @@ -0,0 +1,1015 @@ +# Allegiance — retail mechanism, wire map, ACE status, acdream status + +**Campaign:** FA. **Lane:** C — allegiance mechanics + complete wire map. +**Date:** 2026-08-11. **Mode:** research only — nothing built, nothing +launched, no source file touched except this doc. + +**Sources and how to read the citations.** + +| Short form | Full path | +|---|---| +| `pseudo-c:` | `docs/research/named-retail/acclient_2013_pseudo_c.txt` | +| `acclient.h:` | `docs/research/named-retail/acclient.h` | +| `symbols.json` | `docs/research/named-retail/symbols.json` (PDB, 2013-09-06 EoR build) | +| `ACE/…` | `C:\Users\erikn\source\repos\acdream\references\ACE\…` | +| `Chorizite/…` | `C:\Users\erikn\source\repos\acdream\references\Chorizite.ACProtocol\…` | +| `src/…`, `tests/…` | this worktree | + +> **Worktree caveat.** `references/` is NOT checked out in this worktree +> (only `references/WorldBuilder/` exists here). ACE, Chorizite and +> holtburger were read from the main clone at +> `C:\Users\erikn\source\repos\acdream\references\`. Line numbers are from +> that clone. Same for `docs/research/2026-06-04-wire-message-catalog.md`, +> which exists in the main clone's working tree but not in this worktree. + +--- + +## 0. Verdict summary + +| Question | Answer | +|---|---| +| Are the opcodes right? | **Every one verified from the retail binary itself**, not inferred. The task prompt's parentheticals were all correct: swear `0x001D`, break `0x001E`, info request `0x027B`, `AllegianceUpdate` `0x0020`, `AllegianceInfoResponse` `0x027C`. §3 gives the address of the literal store for each. | +| How many allegiance messages are there? | **27 C→S GameActions + 5 S→C GameEvents.** All 27 senders live in one class, `CM_Allegiance` (`symbols.json`, 27 `Event_*` entries), and every one is a fixed-shape flat body — no PackObj, no flags word, on the C→S side. | +| Is the tree a flat list or a real tree? | **A real tree, assembled client-side from a flat record vector.** Each record after the first carries a `treeParent` guid; `AllegianceHierarchy::Add @0x005B6E90` inserts as left-child/right-sibling. **A record whose `treeParent` is not already in the tree makes `AllegianceHierarchy::UnPack` return 0 and the client drops the ENTIRE message** (§4.4). Parent-before-child ordering is a hard wire contract, not a nicety. | +| Is the hierarchy blob version-gated? | **Yes, eleven separate gates on a `u16 oldVersion` field** (`acclient.h:2979-2994` + `pseudo-c:454484`). ACE always writes `0x000B` (newest), so against ACE the gates are inert — but a parser that hardcodes the 0x0B layout is only correct because ACE is generous. §4.2 has the full gate table. | +| Does the client enforce the level rules for swearing? | **No.** `gmAllegianceUI::UpdateSwearButton @0x004908E0` gates only on "no patron / have a selection / selection is a player / selection is not already in my allegiance". Level, distance, vassal count, lock, ban are **all server-side**. | +| Does ACE implement the whole feature? | Swear / break / boot / passup / motd / name / officers / titles / bans / lock / approved-vassal are **all implemented server-side**. But the `AllegianceUpdate` hierarchy blob **deliberately zeroes officers, officer titles, motd, motdSetBy, nameLastSetTime, isLocked and approvedVassal**, and never fills `timeOnline`/`allegianceAge` (§5.2). Everything except the tree itself reaches the player as chat text. | +| What does acdream have today? | **One live path and one dead scaffold.** Live: `@allegiance info` — request `0x027B` + a full `AllegianceProfile` parser + retail-verbatim text lines. Dead: `AllegianceRequests` (swear/break builders) and `AllegianceTree`/`AllegianceMath`, referenced only by their own unit tests. **Zero handlers for `0x0020` / `0x01C8` / `0x027A` / `0x0003`.** No allegiance panel. §6. | +| Biggest single trap for the implementer | The **already-shipped `AllegianceProfile` parser in `ClientCommandResponses.cs` is the one to reuse** — do not write a second one for the panel. It is byte-correct against ACE today; it needs the version gates added and its skipped fields surfaced, not a rewrite. §7.2. | + +Two defects found in passing, both in acdream, both currently harmless +because the code is unreachable: + +1. **`AllegianceMath.ComputePassup` is a mis-transcription of the retail + formula.** `src/AcDream.Core/Allegiance/AllegianceTree.cs:177` computes + `(50.0 + 22.5 * loyaltyLevel) / 291.0`. The real formula (ACE + `Managers/AllegianceManager.cs:242`, quoting retail) is + `50.0 + 22.5 * (loyalty / 291)`, **as a percentage** — i.e. `× 0.01` at + the end. acdream's version returns ~1000× the correct number for a + maxed vassal and has the loyalty term inside the wrong parenthesis. + The unit test at + `tests/AcDream.Core.Tests/Allegiance/AllegianceTreeTests.cs:94` locks in + the wrong value. **Recommendation: delete the class** (§7.3) — the + client never needs this number; the server sends `_cp_tithed`. +2. **`AllegianceTree` models a patron→vassal edge the wire does not + carry.** Its `UpsertNode(guid, name, patronGuid, rank)` assumes the + server names each node's patron. The wire names each record's + **tree parent**, which for ACE's own writer is *not* always the real + patron (§5.3 — ACE hangs a non-monarch patron directly off the monarch). + Also delete; the parsed record list plus `treeParent` is the tree. + +--- + +## 1. Retail client mechanism + +### 1.1 The three actors + +| Class | Address | Role | +|---|---|---| +| `ClientAllegianceSystem` | ctor `0x0056A150` (`pseudo-c:375113`) | Owns the ONE cached `CAllegianceProfile` for the session (`acclient.h:40628-40632`). Receives all four S→C allegiance events and re-broadcasts them as local notices. Cleared on `OnEndCharacterSession @0x00569FA0`. | +| `gmAllegianceUI` | ctor `0x00490B40`; struct `acclient.h:56038-56061` | The panel. Subscribes/unsubscribes, renders monarch/patron/self/vassals, owns four dialog contexts, sends swear/break/kick. | +| `CM_Allegiance` | `0x006A6920`–`0x006A7F1x` | The message layer. 27 `Event_*` senders (C→S), 4 `SendNotice_*` (system→UI), 3 `DispatchUI_*` (wire→system). | + +The data path is strictly one-way through the system object: + +``` +wire → UIQueueManager dispatch switch (pseudo-c:359190+) + → CM_Allegiance::DispatchUI_* (unpack) + → ClientAllegianceSystem::Handle_Allegiance__* (cache into m_allegianceProfile) + → CM_Allegiance::SendNotice_* (local fan-out) + → gmAllegianceUI::RecvNotice_* (repaint) +``` + +`ClientAllegianceSystem::Handle_Allegiance__AllegianceUpdate @0x0056A120` +(`pseudo-c:375103`) does exactly two things: `AllegianceProfile::operator=` +into the cached profile, then `SendNotice_AllegianceUpdate`. **The panel +never reads the wire; it reads the cache.** Any acdream port should keep +that split — a Runtime-owned `AllegianceProfile` snapshot, a presentation +layer that reads it. + +### 1.2 Panel lifecycle — when `UpdateRequest` fires + +`CM_Allegiance::Event_UpdateRequest(u32)` (opcode `0x001F`) is a +**subscribe/unsubscribe toggle**, not a one-shot query: + +| Site | Address | Arg | +|---|---|---| +| `gmAllegianceUI::PostInit` tail block | `0x004911C6` (`pseudo-c:157414`) | `1` | +| `gmAllegianceUI::RecvNotice_PlayerDescReceived` | `0x00490D59` (`pseudo-c:157175`) | `1` | +| `gmAllegianceUI::OnVisibilityChanged`, visible branch | `0x004912DD` (`pseudo-c:157495`) | `1` | +| `gmAllegianceUI::OnVisibilityChanged`, hidden branch | `0x00491311` (`pseudo-c:157455`) | `0` | + +`RecvNotice_PlayerDescReceived @0x00490D40` also calls +`ClientUISystem::IncrementBusyCount` and sets a latch; `Update @0x00492670` +and the hidden branch both `DecrementBusyCount` when `m_bAwaitingUpdate` +is set. **Retail shows the busy cursor from "I asked" until "the update +landed".** That is the whole reason `AllegianceUpdateDone (0x01C8)` and +`AllegianceUpdateAborted (0x0003)` exist as separate terminators. + +### 1.3 Swear — the complete two-sided flow + +**Initiator (would-be vassal):** + +1. Select a player. `gmAllegianceUI::UpdateSwearButton @0x004908E0` + (`pseudo-c:156945`) enables the button (`SetState(1)`) **only** when all + of: `AllegianceProfile::GetPatron(self) == 0` (I have no patron), + `ACCWeenieObject::selectedID != 0`, `selectedID != player_id`, + `GetWeenieObject(selectedID)->IsPlayer()`, and + `AllegianceProfile::GetData(selectedID) == 0` (they are not already in + my allegiance). Otherwise `SetState(0xD)` — retail's disabled state. + **There is no level, distance, or vassal-count check in the client.** +2. Click → `ListenToElementMessage @0x00493030` sees element `0x10000263` + → `MakeSwearConfirmationDialog @0x004927B0`. This is a **local** + dialog built via `DialogFactory::MakeDialogInCurrentUI`, storing its + handle in `m_swearContext`. Text is a StringInfo with the target's + `GetObjectName(NAME_APPROPRIATE)` bound as variable 0. +3. On OK → `CM_Allegiance::Event_SwearAllegiance(m_iidPossibleNewPatron)` + (`pseudo-c:158356`; see the BN artifact note in §8). + +**Target (would-be patron):** + +4. Server sends the **generic** `Character.ConfirmationRequest` event + (`0x0274`) with `ConfirmationType = 1` (`ALLEGIANCE_SWEAR_CONFIRM`, + `acclient.h:6976-6987`). +5. `ClientUISystem::Handle_Character__ConfirmationRequest @0x005640A0` + switches on the type; `case 1` → + `CM_Allegiance::SendNotice_SwearAllegianceRequest(text, context)` + (`pseudo-c:368633`). +6. `gmAllegianceUI::RecvNotice_SwearAllegianceRequest @0x00493110` → + `MakeAcceptSwearConfirmationDialog`, storing + `m_uiAcceptSwearServerContextID`. +7. On close → `gmAllegianceUI::CloseAcceptSwearConfirmationDialog + @0x00490A50` sends + `CM_Character::Event_ConfirmationResponse(1, contextId, accepted)` + — GameAction **`0x0275`**, 0x18-byte body (`pseudo-c:679924`). + +**There is no allegiance-specific accept message.** The accept rides the +shared confirmation pipeline, which acdream already implements end to end +(§6.4). + +### 1.4 Break and kick — same opcode, different target + +Both are `Event_BreakAllegiance(u32)` (`0x001E`): + +| Path | Handler | Target guid | +|---|---|---| +| Break button `0x10000264` → confirm | `CloseBreakConfirmationDialog @0x00490A90` (`pseudo-c:157046`) | `AllegianceProfile::GetPatron(self)` — **my patron** | +| Kick button `0x10000265` → confirm | `CloseKickConfirmationDialog @0x00490B00` (`pseudo-c:157073`) | `m_iidPossibleKickedVassal` — **the selected vassal** | + +`gmAllegianceUI::UpdateBreakButton @0x004909D0` enables Break iff +`GetPatron(self) != 0`. The Kick button is enabled from the vassal +list-box selection handler (`ListenToElementMessage`, `idMessage == 4`, +reading attribute `0x10000001` off the selected row). + +ACE mirrors this exactly: `Player.IsBreakable` accepts either patron or +vassal (`ACE/Source/ACE.Server/WorldObjects/Player_Allegiance.cs:406-428`). + +### 1.5 Panel population — the tree walk + +`gmAllegianceUI::UpdateVassalsData @0x00492340` (`pseudo-c:158470`): +`UIElement_ListBox::Flush`, clear `m_iidSelectedVassal`, then + +``` +id = AllegianceProfile::GetFirstVassal(profile, player_id, &data) +while (id != 0) { + row = AddItemFromTemplateList(...) + SetAttribute_InstanceID(row, 0x10000001, id) + GetChildRecursive(row, 0x10000268) -> SetText(AllegianceData::GetFullName(data)) + GetChildRecursive(row, 0x10000269) -> SetText(...) // second column + id = AllegianceProfile::GetNextVassal(profile, id, &data) +} +``` + +**There is no paging.** The list box holds every direct vassal (retail caps +that at 11, enforced server-side). The tree accessors are trivial pointer +walks over the assembled node graph: + +| Accessor | Address | Implementation | +|---|---|---| +| `GetMonarch` | `0x005B6A80` | `LookUp(m_pMonarch->_data._id)` | +| `GetPatron(id)` | `0x005B6DD0` | `Search(id)->_patron->_data` | +| `GetFirstVassal(id)` | `0x005B6E10` | `Search(id)->_vassal->_data` (first child) | +| `GetNextVassal(id)` | `0x005B6E50` | `Search(id)->_peer->_data` (next sibling) | +| `GetData(id)` | `0x005B6AE0` | `LookUp(id)` | + +`AllegianceData::GetFullName @0x005B6950` = `" <name>"` when +`AllegianceSystem::GetTitle(rank, heritage, gender)` succeeds, else the +bare name. `AllegianceSystem` has 20 per-heritage/gender title tables +(`symbols.json`, `AllegianceSystem::Get*Title`, `0x005B7BC0`–`0x005B8DD0`); +ACE has an independent port at `ACE/Source/ACE.Server/Entity/AllegianceRank.cs`. + +`AllegianceData::IsLoggedIn @0x005B6550` = `_bitfield & 1`. + +### 1.6 Logon/logoff notices — the `ShowAllegianceLogons` semantics + +`gmAllegianceUI::RecvNotice_AllegianceLogin @0x00492220` +(`pseudo-c:158415`) does: + +1. `AllegianceProfile::GetData(cachedProfile, guid, &data)` — **if the guid + is not in the cached profile, return silently, no text.** So a member + outside your own monarch/patron/self/vassal slice produces nothing even + if the server sends the event. +2. Build `GetFullName(data)` + one of two literal strings selected by the + bool arg (BN renders the two string pointers as bogus vftable members — + see §8). +3. `ClientSystem::AddTextToScroll(text, 0, 1, 0)`. + +**Critical semantics finding: the client does NOT consult +`DisplayAllegianceLogonNotifications` here.** A full-file grep of the +pseudo-C for that accessor (`pseudo-c:171333,171339,171342,366545,366548, +486601,486609,487502,487792,487794`) finds **only** the Options-page +checkbox row, the chargen default sweep, and the `PlayerModule` get/set/ +switch cases. There is no consumer. The bit is **purely a server-side +filter**: retail's server decides whether to send `0x027A` at all. ACE +implements exactly that — +`Player_Allegiance.cs:454` (login) / `:466` (logout) iterate +`Allegiance.OnlinePlayers` and send only to members whose +`CharacterOption.ShowAllegianceLogons` is set. + +Same finding for `IgnoreAllegianceRequests` (`pseudo-c:171301,171307, +171310,366303,366306,485960,485968,487387,487677,487679`): Options row + +chargen + `PlayerModule` plumbing, **no client consumer**. ACE consumes it +at `Player_Allegiance.cs:316-322`, refusing the swear and sending +`WeenieError.YourOfferOfAllegianceWasIgnored` (`0x048E`) to the initiator. + +This **vindicates acdream's AD-78 classification of both rows as +store-only/dimmed** (§6.5) — retail's own client does not read them either. + +`HearAllegianceChat` is the opposite: it IS read client-side, as the +`hearOption` argument to `ClientCommunicationSystem::SendTurbineChat(..., +Allegiance_ChatTypeEnum, text, PlayerModule::HearAllegianceChat(...))` +(`pseudo-c:394269`, `396405`), and it is set by the `/allegiance chat +on|off` verbs (`pseudo-c:380739` sets 1, `:380867` sets 0, +`ClientCommunicationSystem::DoAllegianceChat @0x00575CB0` sets it from the +parsed argument at `pseudo-c:386302`). + +### 1.7 Allegiance chat + +Two distinct channels, both already understood by acdream's chat work: + +* **Turbine allegiance room** — `Allegiance_ChatTypeEnum = 0x1` + (`acclient.h:4467`). Room id comes from + `ChatRoomTracker::GetAllegianceRoomID @0x004F0E90` and from the + hierarchy blob's `m_chatRoomID`. `AllegianceChatBlob : TurbineChatBlob` + (`acclient.h:59921`) adds one field, `m_monarchID`. Sent via + `ClientCommunicationSystem::DoTurbineChat_Allegiance @0x0057EBA0`. +* **Legacy `AllegianceBroadcast` channel** — bit `0x02000000`, verb `@a`, + via `DoAllegianceBroadcast @0x005761F0`. acdream's `ChannelResolver.cs:44` + already maps this, and `ChatChannelInfo.cs:78` already marks it + self-echo. + +acdream's `TurbineChatMembershipGate.cs:74-76,107-110` already gates the +allegiance room join on `AllegianceRoom != 0` **and** the +`HearAllegianceChat` bit — which is the retail rule. **No allegiance chat +work is needed for the panel campaign.** + +### 1.8 The `/allegiance` verb tree in the 2013 client + +`ClientCommunicationSystem::DoAllegiance @0x0057D5A0` (`pseudo-c:393086`) +is a linear string-compare chain. Verbs recovered from the literal +comparisons in address order: `boot`, `info`, `chat`, (alias +`data_7e0cb8`), `broadcast`, (alias `data_7e0ca8`), (`data_7e0ca4`), +`officer`, `title`, `hometown`, … plus the sibling entry points +`DoAllegianceBan @0x005762A0`, `DoAllegianceLock @0x00576E70`, +`DoAllegianceName @0x00576C80`, `DoAllegianceOfficerTitle @0x00576A10`, +`DoAllegianceHouse @0x0056EF70`, `DoAllegianceHometown @0x0056EF10`. +Help text: `HelpAllegiance @0x0057AE10`. + +**So yes — the 2013 client exposes allegiance name, motd, officer and +officer-title management, but only as chat verbs, never as panel +widgets.** `gmAllegianceUI` has no name field, no motd field, no officer +list; its only text elements are `m_pAllegianceName`, `m_pPlayerFollowers`, +`m_pPlayerRank`, `m_pMonarchName`, `m_pMonarchFollowers`, `m_pPatronName` +(`acclient.h:56043-56055`). + +--- + +## 2. Master table + +Direction is C→S unless noted. "Retail anchor" is the address of the +opcode literal store inside the `CM_Allegiance` sender, which is the +strongest possible provenance — the retail client writing its own opcode. + +| # | Feature | Retail anchor | C→S wire | S→C wire | ACE status | acdream status | Recommendation | +|---|---|---|---|---|---|---|---| +| 1 | **Swear allegiance** | `Event_SwearAllegiance` opcode store `0x006A71EA` (`pseudo-c:686090`) | `0x001D` + `u32 targetGuid` (16 B) | `0x0274` confirm to patron → `0x0020`+`0x01C8` to both | **Full.** `GameActionAllegianceSwearAllegiance.cs:9-11`; `Player_Allegiance.cs:66-137`; distance gate 2.0 m, `IsPledgable` at `:296-401` | Builder exists, **unwired** (`AllegianceRequests.BuildSwear`, `src/AcDream.Core.Net/Messages/AllegianceRequests.cs:34`) | **Implement** | +| 2 | **Break (from patron)** | `0x006A69DA` (`pseudo-c:685669`) | `0x001E` + `u32 patronGuid` (16 B) | `0x0020`+`0x01C8` | **Full.** `GameActionAllegianceBreakAllegiance.cs:9-11`; `Player_Allegiance.cs:170-261` | Builder exists, unwired | **Implement** | +| 3 | **Break (kick vassal)** | same as #2 | `0x001E` + `u32 vassalGuid` | `0x0020`+`0x01C8` | **Full.** `IsBreakable` accepts vassal (`Player_Allegiance.cs:418-428`) | none | **Implement** | +| 4 | **Panel subscribe/refresh** | `0x006A72BA` (`pseudo-c:686147`) | `0x001F` + `u32 on` (16 B) | `0x0020` then `0x01C8` | **Partial.** `GameActionAllegianceUpdateRequest.cs:12` *reads* `uiPanel` then **ignores it** — always replies once, never treats it as a subscription | none | **Implement send; do not depend on subscription** | +| 5 | **Tree/profile push** | `pseudo-c:359250-359259` (dispatch case `0x20`) | — | **S→C `0x0020`**: `u32 rank` + `AllegianceProfile` | **Full but lossy.** `GameEventAllegianceUpdate.cs:20-25`; also pushed on tree change (`Allegiance.cs:361`, `AllegianceManager.cs:398`) | **no handler** | **Implement — reuse §7.2 parser** | +| 6 | **Update terminator** | `CM_Allegiance` has no sender; consumed by `gmAllegianceUI::Update` busy-count | — | **S→C `0x01C8`**: `u32 WeenieError` | **Full.** `GameEventAllegianceAllegianceUpdateDone.cs:10` | enum only (`GameEventType.cs:55`) | **Implement (clears busy)** | +| 7 | **Update aborted** | `DispatchUI_AllegianceUpdateAborted @0x006A6950`, tests `*(u32*)arg2 == 3` (`pseudo-c:685651`) | — | **S→C `0x0003`**: `u32 WeenieError` | **Declared, never sent.** Only `GameEventType.cs:5`; no `GameEvent*` class | enum only (`GameEventType.cs:16`) | **Store** (parse + log; ACE will never send it) | +| 8 | **Logon/logoff notice** | `DispatchUI_AllegianceLoginNotificationEvent @0x006A6920`, tests `== 0x27a` (`pseudo-c:685636`) | — | **S→C `0x027A`**: `u32 charGuid` + `u32 bool isLoggedIn` | **Full.** `GameEventAllegianceLoginNotification.cs:12-13`; gated on recipient's `ShowAllegianceLogons` (`Player_Allegiance.cs:454,466`) | **no handler** | **Implement** (2 chat lines) | +| 9 | **`@allegiance info`** | `0x006A7829` (`pseudo-c:686446`) | `0x027B` + `String16L name` | **S→C `0x027C`**: `u32 targetGuid` + `AllegianceProfile` | **Full.** `GameActionAllegianceInfoRequest.cs:13-15`; `GameEventAllegianceInfoResponse.cs:10-11` | **LIVE end to end** — `ClientCommandRequests.cs:262`, `ClientCommandResponses.cs:241-379`, wired at `GameEventWiring.cs:192-197` | **Done — reuse** | +| 10 | **Boot from allegiance** | `0x006A78E9` (`pseudo-c:686474`) | `0x0277` + `String16L name` + `u32 accountBoot` | `0x0020`+`0x01C8` | **Full.** `GameActionBreakAllegianceBoot.cs:11-14` | none | **Store** (chat verb `@allegiance boot`) | +| 11 | **Query allegiance name** | `0x006A7036` (`pseudo-c:686018`) | `0x0030`, no payload (12 B) | system chat text | **Full.** `Player_Allegiance.cs:578-596` | none | **Store** | +| 12 | **Clear allegiance name** | `0x006A6AA6` | `0x0031`, no payload | system chat text | **Full.** `Player_Allegiance.cs:630-651`; requires Castellan | none | **Store** | +| 13 | **Set allegiance name** | `0x006A7C29` (`pseudo-c:686618`) | `0x0033` + `String16L name` | system chat text | **Full but unvalidated.** `Player_Allegiance.cs:598-628` — its own TODO lists 7 missing name checks | none | **Store** | +| 14 | **Set officer** | `0x006A7CE9` (`pseudo-c:686646`) | `0x003B` + `String16L name` + `u32 AllegianceOfficerLevel` | system chat text | **Full.** `Player_Allegiance.cs:767-828` | none | **Store** | +| 15 | **Set officer title** | `0x006A7DED` (`pseudo-c:686705`) | `0x003C` + `u32 rank` + `String16L title` — **note: u32 BEFORE the string** | system chat text | **Full.** `Player_Allegiance.cs:699-738` | none | **Store** | +| 16 | **List officer titles** | `0x006A6F16` | `0x003D`, no payload | system chat text | **Full.** `Player_Allegiance.cs:676-697` | none | **Store** | +| 17 | **Clear officer titles** | `0x006A6B36` | `0x003E`, no payload | system chat text | **Full.** `Player_Allegiance.cs:740-765` | none | **Store** | +| 18 | **Lock action** | `0x006A6DBA` (`pseudo-c:685885`) | `0x003F` + `u32 AllegianceLockAction` | system chat text | **Full.** `GameActionDoAllegianceLockAction.cs` | none | **Store** | +| 19 | **Set approved vassal** | `0x006A7B69` (`pseudo-c:686590`) | `0x0040` + `String16L name` | system chat text | **Full.** `GameActionSetAllegianceApprovedVassal.cs` | none | **Store** | +| 20 | **Chat gag** | `0x006A7729` (`pseudo-c:686388`) | `0x0041` + `String16L name` + `u32 on` | system chat text | **Full.** `GameActionAllegianceChatGag.cs` | none | **Store** | +| 21 | **House action** | `0x006A6CEA` (`pseudo-c:685828`) | `0x0042` + `u32 AllegianceHouseAction` | house messages | **Full.** `GameActionDoAllegianceHouseAction.cs` | none | **Store** (house scope) | +| 22 | **Set MOTD** | `0x006A7ED9` (`pseudo-c:686763`) | `0x0254` + `String16L motd` | system chat text | **Full.** `Player_Allegiance.cs:524-549`; requires Speaker | none | **Store** | +| 23 | **Query MOTD** | `0x006A70C6` | `0x0255`, no payload | system chat text | **Full.** `Player_Allegiance.cs:502-522`; also auto-sent 3 s after login (`:436-439`) | none | **Store** | +| 24 | **Clear MOTD** | `0x006A6C56` | `0x0256`, no payload | system chat text | **Full.** `Player_Allegiance.cs:551-576` | none | **Store** | +| 25 | **Chat boot** | `0x006A765E` (`pseudo-c:686360`) | `0x02A0` + `String16L name` + `String16L reason` | system chat text | **Full.** `GameActionAllegianceChatBoot.cs` | none | **Store** | +| 26 | **Add ban** | `0x006A7589` (`pseudo-c:686332`) | `0x02A1` + `String16L name` | system chat text | **Full.** `GameActionAddAllegianceBan.cs`; `Allegiance.cs:383-395` | none | **Store** | +| 27 | **Remove ban** | `0x006A79E9` | `0x02A2` + `String16L name` | system chat text | **Full.** `GameActionRemoveAllegianceBan.cs` | none | **Store** | +| 28 | **List bans** | `0x006A6E86` | `0x02A3`, no payload | system chat text | **Full.** `GameActionListAllegianceBans.cs` | none | **Store** | +| 29 | **Remove officer** | `0x006A7AA9` | `0x02A5` + `String16L name` | system chat text | **Full.** `Player_Allegiance.cs:830-882` | none | **Store** | +| 30 | **List officers** | `0x006A6FA6` | `0x02A6`, no payload | system chat text | **Full.** `Player_Allegiance.cs:653-674` | none | **Store** | +| 31 | **Clear officers** | `0x006A6BC6` | `0x02A7`, no payload | system chat text | **Full.** `Player_Allegiance.cs:884-...` | none | **Store** | +| 32 | **Recall hometown** | `0x006A7156` | `0x02AB`, no payload | teleport | **Full.** `GameActionRecallAllegianceHometown.cs` | **LIVE** — `ClientCommandRequests.cs:257`, `ClientCommandController.cs:335-336` | **Done** | +| 33 | **XP passup** | — (no client message) | — | reaches client as `_cp_tithed`/`_cp_cached` in `AllegianceData`, plus a login chat line | **Full.** `AllegianceManager.cs:240-320` | none needed | **Neither — server-only** | +| 34 | **Allegiance chat** | `DoTurbineChat_Allegiance @0x0057EBA0` | `0xF7DE` TurbineChat, `ChatType = 1` | `0xF7DE` | **Full** | **LIVE** — `TurbineChatMembershipGate.cs:74-110` | **Done** | +| 35 | **`IgnoreAllegianceRequests` bit** | `PlayerModule::IgnoreAllegianceRequests @0x005D2AE0` | `0x0005` (auto-save) / `0x01A1` blob | in `0x0013` PlayerDescription options word | **Consumed.** `Player_Allegiance.cs:316-322` | **stored + dimmed** — `CharacterOptionTable.cs:112` (Options1 `0x00000004`, autoSave), `CharacterOptionsPageController.cs:195` | **Correct as-is** | +| 36 | **`ShowAllegianceLogons` bit** | `PlayerModule::DisplayAllegianceLogonNotifications @0x005D3200` | same | same | **Consumed.** `Player_Allegiance.cs:454,466` | **stored + dimmed** — `CharacterOptionTable.cs:135` (Options1 `0x08000000`, NOT autoSave) | **Correct as-is** | +| 37 | **`HearAllegianceChat` bit** | `PlayerModule::HearAllegianceChat @0x005D3560` | same | same | **Consumed** at `Player_Allegiance.cs:138-139` (auto-joins Turbine room on swear) | **LIVE, not dimmed** — `CharacterOptionTable.cs:138` (Options1 `0x40000000`, autoSave) | **Correct as-is** | + +**Recommendation legend.** *Implement* = the FA panel cannot work without +it. *Store* = parse/build it and keep the state, but no panel widget in +retail either — it is a chat verb; ship it with the chat-command registry, +not the panel. *Done* = already live in acdream. + +--- + +## 3. C→S field order (golden-byte-vector sources) + +### 3.1 The envelope every `CM_Allegiance` sender writes + +All 27 senders share one shape, visible in every body: + +``` +OrderHdr::Pack(&hdr, &cursor, totalSize) // 8 bytes: u32 0xF7B1, u32 gameActionSequence +*(u32*)cursor = <subOpcode>; // 4 bytes +<payload> +Proto_UI::SendToWeenie(buffer, totalSize) +``` + +The arithmetic proves the header size: a no-payload action allocates +`0xC` = 12 bytes (`Event_QueryAllegianceName`, `pseudo-c:686017`), a +one-u32 action allocates `0x10` = 16, a `String16L`-only action allocates +`strPackSize + 0xC`, a `String16L + u32` action allocates +`strPackSize + 0x10`. So **header 8 + opcode 4**, exactly matching +acdream's `AllegianceRequests.Build` (`AllegianceRequests.cs:48-56`) and +`ClientCommandRequests.BuildParameterless/BuildString`. + +After a `u32` field the senders emit the standard PackObj DWORD-alignment +pad loop (`*(u8*)cursor = 0` until 4-aligned). For these fixed layouts the +cursor is always already aligned, so **no padding bytes are ever emitted** +in practice. A `String16L` is `u16 length` + CP-1252 bytes + pad to 4. + +### 3.2 Per-message layouts + +``` +SwearAllegiance 0x001D u32 targetGuid total 0x10 +BreakAllegiance 0x001E u32 targetGuid total 0x10 +AllegianceUpdateRequest 0x001F u32 on(0|1) total 0x10 +QueryAllegianceName 0x0030 — total 0x0C +ClearAllegianceName 0x0031 — total 0x0C +SetAllegianceName 0x0033 String16L name str+0x0C +SetAllegianceOfficer 0x003B String16L name, u32 officerLevel str+0x10 +SetAllegianceOfficerTitle 0x003C u32 rank, String16L title str+0x10 ← u32 FIRST +ListAllegianceOfficerTitles 0x003D — total 0x0C +ClearAllegianceOfficerTitles 0x003E — total 0x0C +DoAllegianceLockAction 0x003F u32 AllegianceLockAction total 0x10 +SetAllegianceApprovedVassal 0x0040 String16L name str+0x0C +AllegianceChatGag 0x0041 String16L name, u32 on str+0x10 +DoAllegianceHouseAction 0x0042 u32 AllegianceHouseAction total 0x10 +SetMotd 0x0254 String16L motd str+0x0C +QueryMotd 0x0255 — total 0x0C +ClearMotd 0x0256 — total 0x0C +BreakAllegianceBoot 0x0277 String16L name, u32 accountBoot str+0x10 +AllegianceInfoRequest 0x027B String16L name str+0x0C +AllegianceChatBoot 0x02A0 String16L name, String16L reason str1+str2+0x0C +AddAllegianceBan 0x02A1 String16L name str+0x0C +RemoveAllegianceBan 0x02A2 String16L name str+0x0C +ListAllegianceBans 0x02A3 — total 0x0C +RemoveAllegianceOfficer 0x02A5 String16L name str+0x0C +ListAllegianceOfficers 0x02A6 — total 0x0C +ClearAllegianceOfficers 0x02A7 — total 0x0C +RecallAllegianceHometown 0x02AB — total 0x0C +``` + +`SetAllegianceOfficerTitle`'s inverted order is the only field-order +surprise in the set, and it is unambiguous in the decomp +(`pseudo-c:686705-686710`: opcode store, then `*(u32*)eax_5 = arg1`, then +the alignment pad, then `PStringBase::Pack(arg2, ...)`). + +**Three-way agreement.** Every layout above matches ACE's reader +(`ACE/Source/ACE.Server/Network/GameAction/Actions/GameAction*.cs`) and +Chorizite's generated `Messages/C2S/Actions/Allegiance_*.generated.cs` +field-for-field. The only encoding question is `bool` width: retail writes +a full `u32`, ACE reads `Convert.ToBoolean(ReadUInt32())`, and Chorizite's +`ReadBool` is `ReadInt32()` under the hood +(`Chorizite/Chorizite.ACProtocol/Extensions/BinaryReaderExtensions.cs:178-181`). +**All three agree on 4 bytes.** (Note this contradicts the "wire encoding +may differ" caution at `docs/research/2026-06-04-wire-message-catalog.md:3138` +for `0x0041` — that concern is resolved: 4 bytes.) + +### 3.3 The shared confirmation response (swear accept) + +``` +ConfirmationResponse 0x0275 u32 confirmType, u32 context, u32 accepted total 0x18 +``` +`CM_Character::Event_ConfirmationResponse @0x006A1210` (`pseudo-c:679924-679935`). +acdream already builds this: `ClientCommandRequests.ConfirmationResponseOpcode += 0x0275u` (`ClientCommandRequests.cs:22`). + +--- + +## 4. S→C field order — `AllegianceProfile` and the tree + +This is the panel's core data structure; the rest of the feature is +plumbing around it. + +### 4.1 The three records + +``` +AllegianceProfile (acclient.h:36297-36303; UnPack @0x005B6B90, pseudo-c:453802) + u32 totalMembers // _total_members + u32 totalVassals // _total_vassals + AllegianceHierarchy + +AllegianceHierarchy (acclient.h:36275-36295; UnPack @0x005B7520, pseudo-c:454484) + u16 recordCount // low half of one u32 read + u16 oldVersion // high half of the same u32 + [version-gated body — see 4.2] + AllegianceData monarchRecord // only if recordCount > 0, NO treeParent + { u32 treeParent; AllegianceData } × (recordCount - 1) + +AllegianceData (acclient.h:35815-35831; UnPack @0x005B6850, pseudo-c:453566) + u32 _id // character guid + u32 _cp_cached // XP accrued while patron was offline + u32 _cp_tithed // lifetime XP contributed upward + u32 _bitfield // AllegianceIndex, acclient.h:7714-7722 + u8 _gender + u8 _hg // heritage group + u16 _rank + if (_bitfield & 0x08 /*HasPackedLevel*/) u32 _level + u16 _loyalty + u16 _leadership + if (_bitfield & 0x04 /*HasAllegianceAge*/) { u32 _time_online; u32 _allegiance_age; } + else { f64 timeOnlineDouble → _ftol2 → _time_online; _allegiance_age = 0; } + String16L _name +``` + +`AllegianceData::UnPack` refuses payloads shorter than `0x20` bytes +(`pseudo-c:453569`). + +`AllegianceIndex` values are identical in retail (`acclient.h:7714-7722`) +and ACE (`ACE/Source/ACE.Server/Network/Enum/AllegianceIndex.cs`): +`LoggedIn 0x1`, `Update 0x2`, `HasAllegianceAge 0x4`, `HasPackedLevel 0x8`, +`MayPassupExperience 0x10`. + +**Two retail behaviours no other reference documents:** + +1. **When `HasPackedLevel` is absent, the client sets `MayPassupExperience` + itself**: `pseudo-c:453601-453606` — `if ((_bitfield & 8) == 0) + this->_bitfield = (_bitfield | 0x10);`. Legacy-packet compatibility. + Harmless against ACE (which always sets `HasPackedLevel`), but a client + port that also sets the bit is *more* faithful than one that does not. +2. **The non-`HasAllegianceAge` branch reads an 8-byte `double`, not a + `u64`** — `pseudo-c:453634-453637` reads `*(uint64_t*)` then converts + with `_ftol2` (the MSVC float→integer helper). Chorizite models this as + `ReadUInt64` (`Types/AllegianceData.generated.cs:90`) and ACE writes + `ulong 0` (`Network/Structure/AllegianceData.cs:115`). Same 8 bytes, so + nothing breaks — but the field is a **double**, and ACE's own comment + calls the branch "probably deprecated". Flagged, not actionable. + +Also note `AllegianceHierarchy::UnPack`'s last act: +`AllegianceData::SetMayPassupExperience(&monarch->_data, 0)` +(`pseudo-c:454601-454603`) — **the monarch can never pass up.** + +### 4.2 The eleven version gates + +`AllegianceVersion` (`acclient.h:2979-2994`) and the exact predicate each +field is behind, straight from `AllegianceHierarchy::UnPack`: + +| Field | Gate | Decomp line | +|---|---|---| +| `m_AllegianceOfficers` (`PHashTable<u32,u32>`) | `oldVersion >= 6` (`MultipleAllegianceOfficersAdded`) | `pseudo-c:454507` | +| **4-byte skip** (the old single `spokesperson` id) | `1 <= oldVersion < 6` **and** ≥4 bytes remain | `pseudo-c:454502-454506` | +| `m_OfficerTitles` (`PSmartArray<PString>`) | `oldVersion >= 9` (`OfficersTitlesAdded`) | `pseudo-c:454522` | +| `m_monarchBroadcastTime` | `oldVersion >= 2` (`PoolsAdded`) **and** ≥4 bytes | `pseudo-c:454531` | +| `m_monarchBroadcastsToday` | same block, ≥4 bytes | `pseudo-c:454541` | +| `m_spokesBroadcastTime` | same block, ≥4 bytes | `pseudo-c:454550` | +| `m_spokesBroadcastsToday` | same block, ≥4 bytes | `pseudo-c:454559` | +| `m_motd`, `m_motdSetBy` | `oldVersion >= 3` (`MotdAdded`) | `pseudo-c:454570,454579` | +| `m_chatRoomID` | `oldVersion >= 4` (`ChatRoomIDAdded`) | `pseudo-c:454588` | +| `m_BindPoint` (`Position`) | `oldVersion >= 7` (`Bindstones`) | `pseudo-c:454596` | +| `m_AllegianceName` + `m_NameLastSetTime` | `oldVersion >= 8` (`AllegianceName`) | `pseudo-c:454604,454606` | +| `m_isLocked` | `oldVersion >= 10` (`LockedState`) | `pseudo-c:454612` | +| `m_ApprovedVassal` | `oldVersion >= 11` (`ApprovedVassal`) | `pseudo-c:454616` | + +`BannedCharactersAdded_AllegianceVersion = 5` gates **nothing** in +`UnPack` — the ban list never rides the hierarchy blob. (`@allegiance ban +list` is opcode `0x02A3` + chat text.) + +**Nothing in the record loop is version-gated** — `treeParent` is always +present for records 1..N-1 regardless of version. + +### 4.3 Sub-record encodings + +* `PHashTable<u32,u32>` header is `u16 count` + `u16 numBuckets`, then + `count × (u32 key, u32 value)`. ACE writes header `numBuckets = 256` + with a comment that the client actually uses 23 + (`Network/Structure/AllegianceHierarchy.cs:164-183`); acdream's parser + already reads it that way (`ClientCommandResponses.cs:256-262`). +* `PSmartArray<PString>` / officer titles is a **bare `int32` count** then + N × `String16L` — **not** the u16/u16 hash header. ACE writes it that + way (`AllegianceHierarchy.cs:185-190`); acdream reads it that way + (`ClientCommandResponses.cs:266-268`). +* `Position` is `u32 cell` + `3 × f32 pos` + `4 × f32 rotation (W,X,Y,Z)` + = 32 bytes (`AllegianceHierarchy.cs:192-212`). +* `String16L` = `u16 byteLength` + CP-1252 bytes + pad to 4. + +### 4.4 Tree assembly — the rule that will bite + +`AllegianceHierarchy::Add(treeParentId, data) @0x005B6E90` +(`pseudo-c:454063`): + +``` +if (data._id == 0) return 0; // reject +if (m_pMonarch == nullptr) { // FIRST record wins the root + m_pMonarch = new AllegianceNode(data); // treeParentId is IGNORED here + m_total++; return 1; +} +parent = (treeParentId != 0) ? Search(treeParentId, m_pMonarch) : nullptr; +if (parent == nullptr) return 0; // ← unknown parent: FAIL +if (parent->_data._id == data._id) return 0; // ← self-parent: FAIL +if (Search(data._id, m_pMonarch) != 0) return 0; // ← duplicate id: FAIL +node = new AllegianceNode(data); +node->_patron = parent; +node->_peer = parent->_vassal; // push front of sibling list +parent->_vassal = node; +m_total++; return 1; +``` + +and the caller (`pseudo-c:454564-454590`) turns **any** `Add` failure into +`return 0` from `UnPack`, which the dispatcher treats as a dead message. + +Three hard consequences for any server or test fixture: + +1. **Parents must precede children in the record vector.** ACE satisfies + this by construction (monarch, then patron, then self, then vassals). +2. **No duplicate guids.** A record repeated anywhere kills the message. +3. **Sibling order is reversed on read** — the last record added under a + parent becomes `_vassal` (first in the `GetFirstVassal`/`GetNextVassal` + walk). The vassal list-box therefore renders in **reverse wire order**. + Worth an eyeball check against retail before calling a port faithful. + +### 4.5 The four S→C events + +``` +0x0020 AllegianceUpdate u32 rank; AllegianceProfile +0x01C8 AllegianceUpdateDone u32 WeenieError +0x0003 AllegianceUpdateAborted u32 WeenieError +0x027A AllegianceLoginNotification u32 characterGuid; u32 isLoggedIn +0x027C AllegianceInfoResponse u32 targetGuid; AllegianceProfile +``` + +Sources, all three independent and in agreement: + +* retail: dispatch case `0x20` at `pseudo-c:359250-359259` (reads a u32, + advances 4, then `AllegianceProfile::UnPack`); `DispatchUI_*` opcode + tests at `pseudo-c:685636` (`0x27a`), `:685651` (`3`), `:686309` + (`0x27c`); `Handle_Allegiance__AllegianceLoginNotificationEvent` reads + `arg2+4` and `arg2+8`. +* ACE: `GameEventAllegianceUpdate.cs:22-25`, + `GameEventAllegianceAllegianceUpdateDone.cs:10`, + `GameEventAllegianceInfoResponse.cs:10-11`, + `GameEventAllegianceLoginNotification.cs:12-13`. +* Chorizite: `Messages/S2C/Events/Allegiance_*.generated.cs`. + +`0x0020` is the ONLY message where the profile is preceded by a `rank` +word; `0x027C` precedes it with a **guid**. A shared parser must take the +leading word as a parameter, not bake it in. + +--- + +## 5. ACE caveats — read this before scoping + +ACE's allegiance *logic* is complete. Its *presentation blob* is not. + +### 5.1 Fields ACE never populates in `AllegianceUpdate`/`InfoResponse` + +`ACE/Source/ACE.Server/Network/Structure/AllegianceHierarchy.cs`: + +| Field | Value ACE actually sends | Line | Note | +|---|---|---|---| +| `officers` | **always empty** | `:74-75` (the `foreach` is commented out), `:175` ("always sent as empty in retail?") | ACE *has* the data (`Allegiance.Officers`) — it just doesn't ship it | +| `officerTitles` | **always empty** | `:78-83` — commented out with *"not in retail packets, breaks decal"* | ACE has `AllegianceSpeakerTitle`/`Seneschal`/`Castellan` | +| `motd`, `motdSetBy` | **forced to `""`** | `:86-89` — *"fixes decal AllegianceUpdate parsing"* | The real motd goes out as `GameMessageSystemChat` instead | +| `nameLastSetTime` | `0` | `:62,153` | never assigned | +| `isLocked` | **`false` hardcoded** | `:63,154` | ACE *does* track `Allegiance.IsLocked` and enforces it (`Player_Allegiance.cs:372`) — it just never tells the client | +| `approvedVassal` | `0` | `:64,155` | same story | +| `monarch/spokes BroadcastTime/Today` | `0` | `:53-56` | never assigned | +| `chatRoomID` | `allegiance.Biota.Id` | `:90` | real | +| `bindPoint` | `allegiance.Sanctuary` or default | `:92-93` | real | +| `allegianceName` | real, falling back to the monarch's name | `:85` | real | + +`ACE/Source/ACE.Server/Network/Structure/AllegianceData.cs`: + +| Field | Value | Line | +|---|---|---| +| `timeOnline`, `allegianceAge` | **always `0`** — explicit TODO | `:59-60,86-89,111-112` | +| everything else | real | `:69-91` | + +**Scoping consequence:** a faithful acdream panel that displays MOTD, +officers, lock state or allegiance age **will show blanks against ACE**, +not because the client is wrong. Either (a) ship those as chat-verb output +only, matching what ACE actually serves, or (b) file ACE-side patches. Do +not chase a "bug" in the parser. + +### 5.2 Behaviour ACE implements that the wire map might not suggest + +* **Swear needs proximity and consent.** `HandleActionSwearAllegiance` + runs `CreateMoveToChain(patron, …, Allegiance_MaxSwearDistance = 2.0f)` + then a `Confirmation_SwearAllegiance` round-trip + (`Player_Allegiance.cs:75`, `:291`). The client's own swear button + has no distance check — the walk is the server's. +* **`AllegianceUpdate` is pushed unsolicited** on tree changes to every + online member (`Allegiance.cs:361` inside `UpdateProperties`, + `AllegianceManager.cs:398` on removal) — **regardless of whether the + panel subscribed.** So a client that never sends `0x001F` will still get + updates on change; it just won't get one on demand. +* **`AllegianceRank` also arrives as a property** — + `GameMessagePrivateUpdatePropertyInt(PropertyInt.AllegianceRank)` + (`Allegiance.cs:352`), and `PropertyInstanceId.Monarch` via + `UpdateProperty` (`:341`). acdream already models both + (`PropertyInt.cs:85`, `PropertyInstanceId.cs:56`) — a second, cheaper + source of truth for the rank display. +* **MOTD is auto-pushed 3 s after login** as broadcast chat + (`Player_Allegiance.cs:433-441`), together with the cached-XP line + *"Your Vassals have produced experience points for you…"*. + +### 5.3 Where ACE's tree shape diverges from retail's + +`AllegianceHierarchy.cs:103-136` builds exactly four kinds of record: +monarch, patron (only if the patron is not the monarch), self, and direct +vassals. Two divergences: + +1. **The patron record is hung off the monarch**, not off the patron's real + patron: `records.Add(new Tuple<>(node.Monarch.PlayerGuid, new + AllegianceData(node.Patron)))` at `:124`. In a deep allegiance the tree + the client assembles is therefore *flattened* — grandpatrons and above + simply are not sent. +2. **`recordCount` counts self + patron + monarch + direct vassals only** + (`:103-111`), so the panel never sees co-vassals or a wider slice. The + comment block at `:95-101` admits this is reverse-engineered from + aclogview and unverified. + +`AllegianceProfile.cs:32-33` sends `totalMembers = monarch.TotalFollowers + 1` +and `totalVassals = node.TotalFollowers` — note `TotalFollowers` is the +recursive count, so **`totalVassals` is not the same as the number of +records in the vassal list** (which is direct vassals only). Retail's panel +shows `m_pPlayerFollowers` from the profile counter and the list from the +tree walk — they legitimately disagree. + +### 5.4 Rules ACE does NOT enforce + +* **The patron-level rule is commented out** — + `Player_Allegiance.cs:341-351`, the `target.Level < Level` check plus its + `WeenieError.AllegianceIllegalLevel` are inside `/* … */`. So on ACE you + can swear to a lower-level patron. Retail's server enforced it (the error + code exists, `0x0410`; acdream already models it at + `src/AcDream.Core/Physics/WeenieError.cs:304`). +* **Allegiance name validation** — `Player_Allegiance.cs:615-622` lists 7 + unimplemented checks (empty, length ≤ 40, uniqueness, bad chars, portal.dat + banned words, 1-day timer). The error codes exist in acdream already + (`WeenieError.cs:530-532`). +* **`AllegianceUpdateAborted (0x0003)` is never sent** — the enum member + exists (`GameEvent/GameEventType.cs:5`) with no message class. + +### 5.5 XP passup — cite, do not re-derive + +`ACE/Source/ACE.Server/Managers/AllegianceManager.cs:240-320`, quoting +retail in its own header comment: + +``` +Generated % = 50.0 + 22.5 * (loyalty/291) * (1.0 + (RT/730) * (IG/720)) +Received % = 50.0 + 22.5 * (leadership/291) * (1.0 + V * (RT2/730) * (IG2/720)) +Passup % = Generated% * Received% / 100.0 +``` +with `V` = `min(0.25 × directVassals, 1.0)` and, for **indirect** (recursive) +passup, the constants drop to `16.0`/`8.0` (`:275-276`). ACE currently pins +all four time terms to their caps (`:268-271` — `Math.Min(Cap, Cap)`), so +time-sworn has no effect there today. + +**The client needs none of this.** `_cp_tithed` and `_cp_cached` arrive +pre-computed in `AllegianceData`. This is the argument for deleting +acdream's `AllegianceMath` rather than fixing it. + +--- + +## 6. acdream today + +### 6.1 Live: `@allegiance info` (feature #9) + +The only fully wired allegiance path. + +* Request: `ClientCommandRequests.BuildAllegianceInfoRequest` + (`src/AcDream.Core.Net/Messages/ClientCommandRequests.cs:262-263`), + opcode constant at `:56`. +* Command: `ClientCommandId.AllegianceInfo` + (`src/AcDream.UI.Abstractions/ClientCommandId.cs:82`), dispatched at + `src/AcDream.App/UI/ClientCommandController.cs:339-340`. +* Parse: `ClientCommandResponses.ParseAllegianceInfoResponse` + (`src/AcDream.Core.Net/Messages/ClientCommandResponses.cs:241-307`) — + a **complete `AllegianceProfile` reader**, including the officers hash + header, the bare-int32 officer-title count, the 32-byte Position, and + the `HasPackedLevel`/`HasAllegianceAge` branches + (`ReadAllegianceData`, `:309-336`). +* Tree accessors ported from retail: `FindData` / `FindPatron` / + `FindVassals` (`:197-238`). +* Render: `FormatAllegianceInfoLines` (`:357-379`) — a verbatim port of + `Handle_Allegiance__AllegianceInfoResponseEvent @0x0056A1D0`, including + the `" *"` online marker and the retail behaviour of printing **nothing** + when the queried player has no record. +* Wiring: `src/AcDream.Core.Net/GameEventWiring.cs:192-197`. + +**Known limitation:** it does not read `oldVersion` (`:250` explicitly +discards it) and assumes the 0x0B layout. Correct against ACE forever; +wrong against any hypothetical older server. Adding the §4.2 gates is a +contained change to one function. + +### 6.2 Live: recall hometown (feature #32) and allegiance chat (#34) + +`ClientCommandRequests.cs:257`, `ClientCommandController.cs:335-336`; +`TurbineChatMembershipGate.cs:74-76,107-110`, +`ChannelResolver.cs:44`, `ChatChannelInfo.cs:78`, +`TurbineChatDisplayNames.cs:37` (allegiance display slot `0x12`). + +### 6.3 Dead scaffolds — recommend deletion + +| File | Status | Evidence | +|---|---|---| +| `src/AcDream.Core.Net/Messages/AllegianceRequests.cs` (57 lines) | Byte-correct swear/break builders. **Zero production callers** — only `tests/AcDream.Core.Net.Tests/Messages/AllegianceRequestsTests.cs` | repo-wide grep for `AllegianceRequests` outside its own file returns only that test | +| `src/AcDream.Core/Allegiance/AllegianceTree.cs` (183 lines) | `AllegianceNode` + `AllegianceTree` + `AllegianceMath`. **Zero production callers** — only `tests/AcDream.Core.Tests/Allegiance/AllegianceTreeTests.cs` | same grep | + +`AllegianceRequests` is worth **keeping and wiring** (its layout is right). +`AllegianceTree`/`AllegianceMath` should be **deleted** — see §0 defects 1 +and 2 and §7.3. + +### 6.4 Confirmation pipeline — already sufficient for swear-accept + +`src/AcDream.App/UI/GameplayConfirmationController.cs` handles `0x0274` +request → dialog → `0x0275` response → `0x0276` done generically. Its own +comment at `:36-39` explicitly says *"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."* + +**One presentational gap.** ACE sends the confirmation `text` = the +vassal's bare `Name` (`Player_Allegiance.cs:91`, +`ConfirmationManager.cs:38`). Retail's type-1 handler binds that name into +a StringInfo template (`MakeSwearConfirmationDialog @0x004927B0` shows the +pattern: `StringInfo::SetStringIDandTableEnum` + +`AddVariable_String`) producing a real sentence. acdream's generic path +(`:51-53`, which only appends `" Continue?"` for types 2/3/5/6) would show +the raw name. **The FA panel should take ownership of type 1 and supply +the sentence**, exactly as retail's `gmAllegianceUI` does. + +### 6.5 Character options — the three allegiance bits + +| Option | id | Word / mask | Auto-save | Client default | acdream row | +|---|---|---|---|---|---| +| `IgnoreAllegianceRequests` | `0x01` | Options1 `0x00000004` | yes | off | **dimmed** (`CharacterOptionsPageController.cs:195`) | +| `DisplayAllegianceLogonNotifications` | `0x18` | Options1 `0x08000000` | no | off | **dimmed** (`:197`) | +| `ListenToAllegianceChat` | `0x1B` | Options1 `0x40000000` | yes | **on** | **not dimmed** — `CharacterOptionsPageController.cs:229`, marked `Live`, comment cites `TurbineChatMembershipGate.cs:107-110` | + +Source of truth: `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs:112`, +`:135`, `:138`. Ids match retail's `PlayerOption` enum +(`acclient.h:4166,4189,4192`); masks match retail's `CharacterOption` +(`acclient.h:3408,3429,3432`). + +Tests that pin the dimming: `tests/AcDream.App.Tests/UI/Layout/ +CharacterOptionsPageControllerTests.cs:226` and `:728` (the "Grouping" +group's six ids, of which two are the allegiance pair) and `:359` (seeds +`IgnoreAllegianceRequests` ON to prove `Bind` reads current values). +Headless declares them by name at +`src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs:30` and +`tests/AcDream.Headless.Tests/HeadlessCharacterOptionsSeederWiringTests.cs:72,84,126,255`. + +**§1.6 confirms this classification is retail-faithful, not a shortcut: +neither bit has a client-side consumer in the retail binary either.** + +### 6.6 Absent entirely + +No `AllegianceUiController` / `AllegiancePanelController` — the layout +directory (`src/AcDream.App/UI/Layout/`) has 60 controllers and none is +allegiance. No handler for `0x0020`, `0x01C8`, `0x027A`, or `0x0003` +(repo-wide grep for those `GameEventType` members returns one doc-comment +reference in the dead `AllegianceTree.cs:28`). No `ConfirmationType` enum. +No swear/break/kick UI. + +### 6.7 Adjacent data acdream already models + +`PropertyInstanceId.Allegiance = 24` (`PropertyInstanceId.cs:56`); +`PropertyInt.AllegianceCpPool = 29`, `AllegianceRank = 30`, +`AllegianceFollowers = 35`, `ItemAllegianceRankLimit = 110`, +`NumAllegianceBreaks = 132`, `AllegianceMinLevel = 163`, +`AllegianceMaxLevel = 164`, `AllegianceSwearTimestamp = 198` +(`PropertyInt.cs`); `PropertyString.AllegianceName = 47`; +`RadarBlipShape.AllegianceMember` (`RetailRadar.cs:29,113-114`) driven by +`IsAllegianceMember` (`RadarBlipColors.cs:62`) — **currently always +`false`, because nothing populates allegiance membership.** Wiring #5 +would light the radar blips for free. +`ItemAppraisalTextFormatter.cs:861` already renders the "Allegiance Rank" +requirement row. +33 allegiance `WeenieError` values are already enumerated +(`src/AcDream.Core/Physics/WeenieError.cs:299-308,389,395-396,402-405, +435-437,447,463,506-512,530-532`) — everything `0x01C8`/`0x0003` can carry. + +--- + +## 7. Implement-vs-store recommendations + +### 7.1 Minimum viable retail allegiance panel + +Six messages, in dependency order: + +1. **`0x0020` `AllegianceUpdate`** — parse `u32 rank` + profile into a + Runtime-owned snapshot. Everything else depends on this. +2. **`0x01C8` `AllegianceUpdateDone`** — clears the busy latch; carries the + `WeenieError` for a failed swear/break. +3. **`0x001F` `AllegianceUpdateRequest`** — send `1` on panel show and on + PlayerDescription, `0` on hide (§1.2). ACE ignores the value but retail + servers do not; send it correctly. +4. **`0x001D` `SwearAllegiance`** — builder already exists. +5. **`0x001E` `BreakAllegiance`** — builder already exists; serves both + Break and Kick with a different target guid. +6. **`0x027A` `AllegianceLoginNotification`** — two chat lines, gated on + "is the guid in my cached profile" (§1.6), **not** on a local option bit. + +Plus: take ownership of `ConfirmationType == 1` in +`GameplayConfirmationController` (§6.4). + +Everything else in §2 is **store**: build the GameAction, route the reply +into the chat log, do not build a widget. Retail did not build widgets for +them either. + +### 7.2 Reuse, do not rewrite, the profile parser + +`ClientCommandResponses.ParseAllegianceInfoResponse` is already a correct +`AllegianceProfile` reader. Refactor it into a shared +`AllegianceProfileReader` taking the profile span (both callers prepend a +different leading u32 — rank vs guid, §4.5), then: + +* add the §4.2 version gates, +* surface the currently-discarded fields the panel needs: `rank`, + `level`, `loyalty`, `leadership`, `cpCached`, `cpTithed`, `gender`, + `heritage`, `chatRoomID`, `bindPoint`, +* keep `FindData`/`FindPatron`/`FindVassals` — they are already ports of + the retail accessors, +* keep the retail assembly rules from §4.4 as the parse contract (reject + unknown parent, reject duplicate id) so a malformed server blob fails + the same way retail fails it. + +The one behaviour to add on top: **sibling order reverses on assembly** +(§4.4) — either build the real linked structure or reverse the per-parent +list when rendering. + +### 7.3 Delete + +* `src/AcDream.Core/Allegiance/AllegianceTree.cs` and + `tests/AcDream.Core.Tests/Allegiance/AllegianceTreeTests.cs` — the tree + model contradicts the wire (§0 defect 2) and the passup math is wrong and + unnecessary (§0 defect 1, §5.5). Deleting is strictly safer than leaving + a wrong formula with a passing test next to a new correct implementation. + +### 7.4 Divergence-register rows this campaign will owe + +* ACE never populates officers / officer titles / motd / lock / + approvedVassal / nameLastSetTime / broadcast counters / timeOnline / + allegianceAge in the profile blob (§5.1) — the panel renders blanks. + **Adaptation**, symptom: "MOTD and officer fields are always empty". +* ACE flattens the patron chain onto the monarch and sends only + monarch/patron/self/direct-vassals (§5.3) — **approximation**, symptom: + "the tree above my patron is missing". +* ACE does not enforce the patron-level rule (§5.4) — **server-side + divergence**, symptom: "I can swear to a level-1 patron". +* `totalVassals` (recursive) versus the direct-vassal list length (§5.3) — + **explained divergence**, symptom: "the follower count doesn't match the + list". + +--- + +## 8. BN decomp artifacts observed (zero-fold / symbol-fold warnings) + +Per the standing BN caution, these are decompiler artifacts in the cited +lines — **not** evidence of the semantics the names suggest. None changes +any conclusion above; all are flagged so a later reader does not "fix" a +non-bug. + +1. **String constants rendered as vftable members.** `pseudo-c:171301` + shows `compute_str_hash(&gmAllegianceUI::`vftable'.RecvNotice_AddSpellShortcut)` + — that operand is the address of the literal + `"ID_PlayerOption_IgnoreAllegianceRequests"` (the real string is at + `pseudo-c:1068811` / `.rdata 0x00805520`), which BN folded into the + nearest preceding symbol. Same class at `pseudo-c:158427,158429` + (the login/logout sentence pair inside `RecvNotice_AllegianceLogin`) + and `:453655` (`data_794098`, the separator inside `GetFullName`). +2. **Field access folded into an unrelated member path.** + `pseudo-c:158356` renders the swear send as + `Event_SwearAllegiance(this->m_hashElementsRegisteredWith.m_intrusiveTable.m_aInplaceBuckets[1])`. + The real field is `gmAllegianceUI::m_iidPossibleNewPatron` + (`acclient.h:56042`), reached by raw offset; BN attributed the offset + to the wrong base. Confirmed by the symmetric kick path + (`pseudo-c:157079`) which BN *does* render correctly as + `this->m_iidPossibleKickedVassal`. +3. **`this` adjusted by a constant on multiple-inheritance thunks.** + `gmAllegianceUI::RecvNotice_AllegianceUpdate @0x00492780` reads + `gmAllegianceUI::Update(((char*)this - 0x5F8))` — the `-0x5F8` is the + `gmNoticeHandler` sub-object adjustment, not a real pointer offset. + Same at `pseudo-c:159260` (`RecvNotice_SwearAllegianceRequest`) and + `:158415`. +4. **Dead comparison idioms.** `(ebx_1 - ebx_1) == 0xff` throughout + `DoAllegiance` (`pseudo-c:393086+`) is BN's rendering of a + sign-extended byte compare against `PStringBase::cmp`'s result; read it + as "strings matched". +5. **No literal-zero opcode folds were found.** Every one of the 27 + `CM_Allegiance::Event_*` senders shows an explicit non-zero opcode + store (§3.2). There is no place in this feature where a `0` operand + might be hiding an id. +6. **`AllegianceHierarchy::UnPack` renders `arg2` as an alias for the + remaining-size variable** in places (`pseudo-c:454518,454530,…`) — BN + reusing the parameter slot as a local. The size arithmetic is still + readable; the *field order* (the part that matters) is unambiguous. + +--- + +## 9. Unknowns for the coordinator + +1. **The Allegiance panel's LayoutDesc id is not known.** Retail element + ids ARE known from the decomp — swear `0x10000263`, break `0x10000264`, + kick `0x10000265`, vassal-row name `0x10000268`, vassal-row second + column `0x10000269`, row instance-id attribute `0x10000001` + (`pseudo-c:159207-159240`, `:158466-158510`) — but the containing + `0x21000xxx` LayoutDesc is not derivable from the decomp and is not in + `docs/research/retail-ui/`. **Resolve with a UI-Studio `--dump` sweep** + the same way CH6a found the chat window's real id (`0x2100006F`, after + an earlier wrong guess of `0x21000006` — see + `src/AcDream.App/UI/Layout/ChatWindowController.cs:30`). Do not guess. +2. **What is the vassal row's second text column (`0x10000269`)?** The + `UpdateVassalsData` body past `pseudo-c:158500` was not fully read. + Candidates: rank title, level, or `_cp_tithed`. One more decomp pass on + `0x00492340`–`0x00492670` settles it. +3. **Sibling render order.** §4.4 shows the assembly reverses wire order. + Whether retail's list box therefore shows newest-vassal-first is a + **visual gate question** — worth one side-by-side check against retail + before the panel is called done. +4. **Does ACE's unsolicited `AllegianceUpdate` on tree change actually + reach a client that never subscribed?** §5.2 says yes by code reading + (`Allegiance.cs:361` is unconditional). Cheap to confirm on the live + ACE with two accounts once #5 is parsed. +5. **`AllegianceHierarchy::GetMinPackSize @0x0041BC20` was not read** — it + sets `AllegianceProfile::UnPack`'s minimum-size guard + (`minPackSize + 8`, `pseudo-c:453805`). Only matters for a + truncation-rejection conformance test. +6. **Officer/motd/name management scope.** All 20-odd management opcodes + are chat verbs in retail, and ACE serves them. Are they in FA's scope, + or does FA stop at the panel? The §2 "store" column assumes panel-only; + if the chat verbs are in scope, they are a mechanical addition to the + existing 152-verb registry with no new parsing work (every reply is + plain system chat). +7. **`Confirmation_SwearAllegiance` text ownership.** §6.4 — should the FA + panel own `ConfirmationType == 1` and compose the sentence locally + (retail-faithful), or should we ask for an ACE-side text change? The + retail-faithful answer is local composition; flagging because it means + touching `GameplayConfirmationController`, which Campaign CH owns. + +--- + +## 10. Executive summary + +1. **Every opcode in the prompt was correct and is now verified from the + retail binary's own opcode stores**, not inferred: swear `0x001D`, break + `0x001E`, subscribe `0x001F`, info request `0x027B`, `AllegianceUpdate` + `0x0020`, `UpdateDone` `0x01C8`, `Aborted` `0x0003`, `LoginNotification` + `0x027A`, `InfoResponse` `0x027C` — 27 C→S actions and 5 S→C events in + total, all field orders three-way agreed across retail, ACE and Chorizite. +2. **The panel's core data structure is a flat record vector the client + folds into a left-child/right-sibling tree**, with eleven version gates + on a `u16 oldVersion` and one brutal rule: a record whose `treeParent` + is not already in the tree makes the client discard the entire message. +3. **acdream already ships a correct `AllegianceProfile` parser** (the + `@allegiance info` path) — reuse it with version gates and more fields + surfaced; it needs no rewrite. There are zero handlers for the other + four allegiance events and no panel. +4. **ACE implements all the allegiance logic but deliberately zeroes + officers, officer titles, MOTD, lock state, approved vassal and + allegiance age in the profile blob**, and flattens the patron chain onto + the monarch — so a faithful panel will legitimately show blanks, and the + plan must scope those as chat-text features, not panel widgets. +5. **The two dimmed allegiance option rows are retail-faithful, not a + shortcut** — the retail client has no consumer for + `IgnoreAllegianceRequests` or `DisplayAllegianceLogonNotifications` + either; both are pure server-side filters. Meanwhile + `src/AcDream.Core/Allegiance/AllegianceTree.cs` is a dead scaffold whose + passup formula is transcribed wrong and whose tree model contradicts the + wire — delete it rather than build on it. diff --git a/docs/research/2026-08-11-fa-fellowship-wire.md b/docs/research/2026-08-11-fa-fellowship-wire.md new file mode 100644 index 00000000..6dadfa2e --- /dev/null +++ b/docs/research/2026-08-11-fa-fellowship-wire.md @@ -0,0 +1,1077 @@ +# Fellowship mechanics + wire map — Campaign FA research lane B + +**Campaign:** FA. **Lane:** B — the complete fellowship feature map, each +feature pinned end-to-end (retail client behavior → wire format → ACE +server handling → acdream current state). +**Date:** 2026-08-11. **Mode:** research only — nothing built, nothing +launched, no source changed. This doc is the only file written. + +**Quality bar / format:** `docs/research/2026-08-10-character-options-map.md`. + +**Sources.** + +| Source | Path | Used for | +|---|---|---| +| BN pseudo-C, Sept 2013 EoR build | `docs/research/named-retail/acclient_2013_pseudo_c.txt` | retail client mechanism + wire builders | +| Verbatim retail headers | `docs/research/named-retail/acclient.h` | `Fellow` / `Fellowship` / `gmFellowshipUI` / `PlayerOption` struct truth | +| PDB symbol table | `docs/research/named-retail/symbols.json` | `class::method` → address | +| PDB-paired binary | `C:\Users\erikn\Downloads\acclient.exe` (ImageBase `0x00400000`, GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`, linker 2013-09-06T00:17:56Z) | **byte-decode of two BN-folded functions** (§7) | +| ACE (server truth) | `references/ACE/Source/` (main repo, not the worktree) | what the server actually implements/validates | +| Chorizite.ACProtocol | `references/Chorizite.ACProtocol/` | field comments, pcap-derived shapes | +| holtburger (client truth) | `references/holtburger/crates/holtburger-protocol/src/messages/fellowship/` | third independent client-side parser | +| acdream | `src/` in this worktree | current state | + +> **Path note.** `references/` is **not** populated in this worktree — +> only `references/WorldBuilder` exists here. Every `references/…` +> citation below resolves against the **main repo** at +> `C:\Users\erikn\source\repos\acdream\references\…`. + +--- + +## 0. Verdict summary + +| Question | Answer | +|---|---| +| Is the fellowship wire format fully pinned? | **Yes, three-way, with retail as tiebreaker.** All 7 C→S opcodes and all 7 S→C opcodes have byte-exact field order from the retail builders/dispatchers, corroborated by ACE and holtburger. Two reference disagreements resolved (§4.1, §4.2). | +| Does acdream implement any of it? | **No. Zero fellowship state, zero inbound parsing.** What exists: the opcode enums, five *uncalled* outbound builders (two of them malformed), four character-option bits, the Fellowship **chat** channel (fully live), and a radar relationship seam hard-wired to `false`. | +| Are acdream's existing builders safe to call today? | **No.** `BuildFellowshipCreate` invents a nonexistent `openness` field and silently sends the WRONG `shareXP` value (§5.2). `BuildFellowshipUpdate`'s `open` parameter names the wrong feature — `0x00A6` is *panel visibility*, and openness is a separate opcode acdream does not have (§5.2). | +| Max fellowship size? | **9, byte-verified.** `Fellowship::IsFull @0x005B9070` decodes to `_currNum >= 9` (§7.1). Matches ACE `Fellowship.cs:26`. | +| Is ACE's XP share table right? | **No — one row is wrong.** Retail's 9-fellow even-split factor is **0.3111111** (byte-decoded, §7.2); ACE uses `.3` (`Fellowship.cs:627`). Retail also has a 10-fellow row (0.28) and a 0.0 default that ACE lacks. | +| Anything else ACE gets wrong? | The locked-fellowship re-recruit window is **900 s in retail**, 600 s in ACE — and ACE's own broadcast string says "15 minutes" (§4.4). Plus two internally-inconsistent `shareLoot` encodings (§4.1). | +| Is `FellowUpdateDone`/`FellowStatsDone` real? | **No — dead in the 2013 client.** Both handlers COMDAT-fold onto `return 0` (§2.7). ACE's own comment guessed this correctly. | +| Where does accept/decline live? | **Not a fellowship opcode.** It rides the shared Confirmation triple `0x0274` / `0x0275` / `0x0276` with `ConfirmationType = 4` (§3.10). acdream already parses `0x0274`/`0x0276` — the type-4 branch is unimplemented. | + +Two facts worth stating up front because they change scope estimates: + +1. **The fellowship panel is a pure projection of one message.** Every + visible field comes from `0x02BE FullUpdate`, incrementally patched by + `0x02C0 UpdateFellow` and removed by `0x00A3`/`0x00A4`/`0x02BF`. There + is no client-side authority to model — except the two display + computations in §2.9 and the button-enable rules in §2.8. +2. **Fellowship chat already works in acdream** (channel bit `0x800`, + `ChatInputParser.cs:87-93`). Only the *roster* is missing. That means + the observable gap is the panel, the radar markers (AP-90), and the + member vitals — not the social channel. + +--- + +## 1. Master table + +Legend for **acdream status**: `absent` = nothing exists; `enum-only` = +the opcode value is declared but nothing reads/writes it; `builder-only` += an outbound builder exists but has **no production caller**; +`live` = shipped and reachable by a user. + +| # | Feature | Retail anchor | C→S wire | S→C wire | ACE status | acdream status | Recommendation | +|---|---|---|---|---|---|---|---| +| 1 | **Create** (name + share-XP) | `gmFellowshipUI::CreateFellowship @0x0048F730` → `CM_Fellowship::Event_Create @0x006A67A0` | `0x00A2` `[str16L name][u32 shareXP]` | `0x02BE` FullUpdate (+ dead `0x01C9`) | Implemented, `GameActionFellowshipCreate.cs:11-14`; Olthoi refusal `Player_Fellowship.cs:22-26`; **no name validation** (§4.6) | **builder-only + MALFORMED** (`SocialActions.cs:123-138`) | **Implement.** Fix the builder first (§5.2). shareXP comes from option `0x0F`, not a dialog checkbox. | +| 2 | **Recruit / invite** | `gmFellowshipUI::RecruitFellow @0x0048E530` → `Event_Recruit @0x006A62B0` | `0x00A5` `[u32 targetGuid]` | (none direct) → `0x0274` ConfirmationRequest to the *target*; `0x02C0` + `0x02BE` to everyone on accept | Implemented, `GameActionFellowshipRecruit.cs:10-13`; gates in `Player_Fellowship.cs:86-110` + `Fellowship.cs:76-132` | **builder-only** (`SocialActions.cs:156-157`) | **Implement.** Target = `ACCWeenieObject::selectedID` (world selection), see §2.3. | +| 3 | **Recruit-target selection** | `gmFellowshipUI::ListenToElementMessage @0x004901C0` case `0xA` | — | — | n/a (client-side) | absent | **Implement** — reuse the existing world-picker selection; no new mechanism. | +| 4 | **Accept / decline** | `gmFellowshipUI::MakeFellowRequestDialog @0x00490620`, `RecvNotice_CloseDialog @0x0048F2C0` → `CM_Character::Event_ConfirmationResponse @0x006A1210` | `0x0275` `[u32 type=4][u32 context][u32 response]` | `0x0274` `[u32 type=4][u32 context][str16L inviterName]`; abort `0x0276` `[u32 type][u32 context]` | Implemented, `GameActionConfirmationResponse.cs:11-15`, `ConfirmationManager.cs:143`, `Confirmation.cs:140-165`; 30 s timeout `ConfirmationManager.cs:19` | `0x0274`/`0x0276` **parsed** (`GameEventWiring.cs:202,212`), type-4 branch **absent** (`GameplayConfirmationController.cs:37` names the gap) | **Implement** — smallest slice; the transport already exists. | +| 5 | **IgnoreFellowshipRequests** option | `PlayerModule::IgnoreFellowshipRequests @0x005D2B30` | `0x0005` `[u32 id=0x02][u32 value]` (auto-save) | — | Read at `Player_Fellowship.cs:98-102` → `FellowshipIgnoringRequests (0x0417)` | **live storage, store-only UI** — id `0x02`, mask `0x00000008` (`CharacterOptionTable.cs:113`), dimmed by AD-78 | **Store only.** ACE is the consumer; acdream has nothing to do beyond sending the bit (already correct). | +| 6 | **FellowshipAutoAcceptRequests** option | `PlayerModule::FellowshipAutoAcceptRequests @0x005D3020`; mutual-exclusion in `CPlayerModule::OnChanged @0x0059A8E0` cases `2`/`0x12` | `0x0005` `[u32 id=0x12][u32 value]` (auto-save) | — | Read at `Fellowship.cs:121` → `AddConfirmedMember(…, true)` (skips the popup) | **live storage + retail-exact mutual exclusion** (`RuntimeCharacterState.cs:821-834`), id `0x12`, mask `0x20000000`; store-only UI | **Store only** — but note it *suppresses feature 4's dialog*, so feature 4's implementation must read this bit or it will show a popup ACE never sent. | +| 7 | **Dismiss** | `gmFellowshipUI::DismissFellow @0x0048E470`, button case `0xB` | `0x00A4` `[u32 guid]` | `0x00A4` `[u32 guid]` to all + `0x02BE` | Implemented, `GameActionFellowshipDismiss.cs:8-11`; leader-only + no-self gates `Player_Fellowship.cs:62-84` | **builder-only** (`SocialActions.cs:152-153`) | **Implement.** | +| 8 | **Quit** | button case `8` (auto-transfers leadership first!) → `Event_Quit(0) @0x006A61E0` | `0x00A3` `[u32 disband]` | `0x00A3` `[u32 quitterGuid]` to self *and* all | Implemented, `GameActionFellowshipQuit.cs:9-11`, `Fellowship.cs:286-375` | **builder-only** (`SocialActions.cs:141-149`) | **Implement**, including the retail pre-quit leader handoff (§2.8). | +| 9 | **Disband** | button case `0xC` → `Event_Quit(1)` | `0x00A3` `[u32 disband=1]` | `0x02BF` (empty body) to all | Implemented, `Fellowship.cs:290-311` | **absent** (no `disband:true` caller) | **Implement** — same opcode as Quit, flag flipped. | +| 10 | **Leader transfer** | `gmFellowshipUI::AssignLeadershipToFellow @0x0048F1E0` → `Event_AssignNewLeader @0x006A5F70` | **`0x0290`** `[u32 newLeaderGuid]` | `WeenieErrorWithString` `0x050E` to old leader + `0x050D` to all + `0x02BE` | Implemented, `GameActionFellowshipAssignNewLeader.cs:8-10`, `Player_Fellowship.cs:112-135`, `Fellowship.cs:378-408` | **absent — opcode not even declared** | **Implement.** Add `0x0290` to `SocialActions`. | +| 11 | **Open vs closed** | button case `9` → `Event_ChangeFellowOpeness @0x006A6040` (client pre-toggles its own `_open_fellow`) | **`0x0291`** `[u32 isOpen]` | `WeenieErrorWithString` `0x050B`/`0x050C` + `0x02BE` | Implemented, `GameActionFellowshipChangeOpenness.cs:9-11`; leader-only + locked refusal `Player_Fellowship.cs:33-48` | **absent — opcode not declared**; `BuildFellowshipUpdate(open:)` is a **misnamed `0x00A6`** | **Implement `0x0291`; rename/repurpose the `0x00A6` builder.** | +| 12 | **Panel visibility → update request** | `gmFellowshipUI::OnVisibilityChanged @0x0048E460` → `Event_UpdateRequest @0x006A6380` | `0x00A6` `[u32 panelOpen]` | `0x02BE` if in a fellowship | Implemented, `GameActionFellowshipUpdateRequest.cs:11-13`; sets `FellowshipPanelOpen`, which **gates the vitals stream** (`Fellowship.cs:723`) | **builder exists but is documented as "open"** | **Implement with correct semantics.** Without it ACE sends **no** vital updates (§4.5). | +| 13 | **Member update stream (vitals)** | `ClientFellowshipSystem::Handle_Fellowship__UpdateFellow @0x005690C0`, `gmFellowshipUI::UpdateFellowVitals @0x0048ED60` | — | `0x02C0` `[u32 guid][Fellow][u32 updateType]`, `updateType = 3 (Vitals)` | Implemented, `Fellowship.OnVitalUpdate` `Fellowship.cs:715-726`, driven from `Player_Tick.cs:84-89` via the `FellowVitalUpdate` latch | absent | **Implement** (parse + roster patch). | +| 14 | **Member update stream (add/full/stats)** | same | — | `0x02C0` with `updateType = 1 (Full)` on join (`Fellowship.cs:166`) | Implemented. **ACE never sends `updateType = 2 (Stats)`** | absent | **Implement**; treat 1/2/3 identically (retail patches the whole `Fellow` regardless — §2.6). | +| 15 | **XP sharing — even/proportional decision** | `Fellowship::RecalculateEvenXPSplitting @0x005B92E0` | — | `_even_xp_split` field of `0x02BE` | `Fellowship.CalculateXPSharing() Fellowship.cs:453-494` — same rule + a 3rd "≤10 levels ⇒ proportional" tier | absent | **Store the wire flag; do NOT re-derive.** The client's local recompute is display-only (§6.2). | +| 16 | **XP sharing — share table** | `FellowshipSystem::GetEvenSplitXPPctg @0x005B9BA0` (**byte-decoded**, §7.2) | — | — | `GetMemberSharePercent() Fellowship.cs:604-632` — **9-fellow row diverges** (§4.3) | absent | **Client-side: needed only for the panel's per-fellow % text** (§2.9). File the ACE divergence. | +| 17 | **XP sharing — proportional weight** | `FellowshipSystem::GetExperienceProportion @0x005B9B80` = `ExperienceToRaiseLevel(L, L+1)` | — | — | `GetXPToNextLevel(Level)` `Fellowship.cs:545,550` — same idea | absent | Same as 16 — panel display only. | +| 18 | **XP sharing — proximity** | *(server-side; no client function)* | — | — | `GetDistanceScalar Fellowship.cs:640-668`: indoor/outdoor mismatch ⇒ 0; different indoor landblock ⇒ 0; ≤600 ⇒ 1.0; ≥1200 ⇒ 0; linear between | absent | **Nothing to implement client-side.** Document only. | +| 19 | **Luminance sharing** | *(no client function; `_lum_cache` field only)* | — | `lumCached` u32 in `Fellow` | `SplitLuminance Fellowship.cs:567-603`: quest lum not shared; otherwise flat split over ALL members, then filtered to `WithinRange` (radar 75 m outdoor / 25 m indoor) | absent | **Display only** (`lumCached`). | +| 20 | **Max size** | `Fellowship::IsFull @0x005B9070` ⇒ `_currNum >= 9` (§7.1) | — | — | `MaxFellows = 9` `Fellowship.cs:26`; enforced `Fellowship.cs:102-106` and again `:151-155` | absent | **Implement** as the Recruit-button enable rule only. Server is authoritative. | +| 21 | **Logoff** | `ClientFellowshipSystem::OnEndCharacterSession @0x005690A0` (frees the local copy) | — | `0x00A3` Quit to remaining members | `LogOut_Inner Player.cs:531-532` → `FellowshipQuit(false)` — **a logoff is a permanent quit, not a suspend** | absent | **Implement teardown** on session end (mirror the existing reset ledgers). | +| 22 | **Death** | — | — | `GameMessageSystemChat` "Your fellow X has died!" | `Player_Death.cs:73-74` → `Fellowship.OnDeath` `Fellowship.cs:728-737`. **Death does NOT remove you from the fellowship.** | chat line renders today (generic system chat) | Nothing extra. | +| 23 | **Portal / teleport** | — | — | none | **No fellowship hook anywhere in ACE's teleport path.** Membership survives; only `GetDistanceScalar` observes the new position. | absent | Nothing. | +| 24 | **Level-up** | — | — | `0x02BE` (via `CalculateXPSharing`) + chat "X is now level N!" | `Player_Xp.cs:333` → `OnFellowLevelUp Fellowship.cs:700-713` | absent | Nothing extra; falls out of `0x02BE`. | +| 25 | **Lock / departed members** | `Fellowship::HandleLockedRemoveFellow @0x005B9270`, `AddFellow @0x005B9480` (**900 s**) | — | `_locked` + `_fellows_departed` in `0x02BE` | `UpdateLock Fellowship.cs:418-448` — **emote-driven only**, no GameAction. **600 s** window (§4.4) | absent | **Parse and store** the two fields; no client action exists (retail has no lock button). | +| 26 | **Fellowship chat** | `Channel` bit `0x800`; `RetailLogTextType 0x13` | `0x0147` ChannelBroadcast family | `0x0147` | `TellFellow Fellowship.cs:266-272`, `BroadcastToFellow :258-263` | **LIVE** — `ChatInputParser.cs:87-93`, `ChannelResolver.cs:39`, `LegacyChannelChatType.cs:78` | Done. | +| 27 | **Radar relationship markers** | `CM_Character::SendNotice_ChangeRadarLook` fired from every fellowship handler | — | — | n/a | seam exists, hard-wired `false` (`RadarBlipColors.cs:60-61`) — **AP-90** | **Implement** as a consumer of the new roster; zero new mechanism. | +| 28 | **Next/Previous fellow hotkeys** | `ClientFellowshipSystem::SelectNextFellow @0x005694F0` / `SelectPreviousFellow @0x005695C0` | — | — | n/a | actions declared (`InputAction.cs:86,88`, bound N/M `KeyBindings.cs:201-202`) — **no consumer** | **Implement** once the roster exists. | +| 29 | **Fellowship panel toggle (F4)** | `gmFellowshipUI` | `0x00A6` on show/hide | — | n/a | action declared (`InputAction.cs:106`, F4 `KeyBindings.cs:210`) — **no consumer, no panel** | **Implement** with the panel. | +| 30 | **Fellowship kill-task / quest sharing** | — | — | — | `Creature_Death.cs:291,414` uses `WithinRange`; `QuestManager` per-fellowship (`Fellowship.cs:46`) | absent | Server-side only. | +| 31 | **Loot permission** | `Fellow._share_loot` | — | `shareLoot` u32 in `Fellow` | `ShareLoot` set once at create from the leader's option `0x11` (`Fellowship.cs:58`); enforced `Corpse.cs:192-198` | option `0x11` stored (`CharacterOptionTable.cs:128`), dimmed | **Store + display**; ACE enforces. | + +--- + +## 2. Retail client mechanism + +### 2.1 Object model + +`acclient.h:36647` — **`Fellow`** (the per-member record; the member GUID +is *not* a field, it is the hash-table **key**): + +```c +struct __cppobj Fellow : PackObj { + AC1Legacy::PStringBase<char> _name; + unsigned int _level; + unsigned int _cp_cache; + unsigned int _lum_cache; + int _share_loot; + unsigned int _max_health; + unsigned int _max_stamina; + unsigned int _max_mana; + unsigned int _current_health; + unsigned int _current_stamina; + unsigned int _current_mana; +}; +``` + +> **Declaration order ≠ wire order.** The wire order is fixed by +> `Fellow::Pack @0x005B9A10` / `Fellow::UnPack @0x005B9AD0` and is +> **different** from the struct above. Use §3.8, never this listing. + +`acclient.h:36731` — **`Fellowship`** (8 packed members): + +```c +struct __cppobj Fellowship : PackObj { + PackableHashTable<unsigned long,Fellow> _fellowship_table; + AC1Legacy::PStringBase<char> _name; + unsigned int _leader; + int _share_xp; + int _even_xp_split; + int _open_fellow; + int _locked; + PackableHashTable<unsigned long,long> _fellows_departed; +}; +``` + +`CFellowship : Fellowship` (`acclient.h:37758`) adds nothing; +`sizeof == 0x44` (68) from the allocation at +`Handle_Fellowship__FullUpdate @0x005693DC`. + +`ClientFellowshipSystem` (`acclient.h:40752`) holds exactly one +`CFellowship*`: + +```c +struct __cppobj ClientFellowshipSystem : ClientSystem { + Turbine_RefCount m_cTurbineRefCount; + CFellowship *m_pFellowship; // null == not in a fellowship +}; +``` + +`gmFellowshipUI` (`acclient.h:56065`) — the panel, and the complete +control inventory: + +```c +struct __cppobj gmFellowshipUI : UIElement_Field, gmNoticeHandler { + CFellowship *m_pFellowship; + unsigned int m_iidSelectedFellow; // list-box selection + 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; +}; +``` + +### 2.2 Create + +`gmFellowshipUI::CreateFellowship @0x0048F730`: + +1. read `m_pFellowshipNameEntryBox` text (`@0x0048F752`); +2. `ACCharGenData::FormatName(&var_24)` (`@0x0048F7AE`) — the **same** + name formatter chargen uses — then write the formatted text back into + the entry box (`@0x0048F7CD`), so the user sees the canonicalised name; +3. `eax_8 = PlayerModule::FellowshipShareXP(...)` (`@0x0048F7F7`); +4. `CM_Fellowship::Event_Create(name, eax_8)` (`@0x0048F82F`). + +**The share-XP flag is the character-option bit, not a dialog control.** +There is no share-XP checkbox in the create flow — the value comes from +`PlayerOption 0x0F` (`CharacterOptions1` bit 18, mask `0x00040000`, +`PlayerModule::FellowshipShareXP @0x005D2F30`). ACE independently states +the same thing at `Fellowship.cs:31`. + +The Create button's enable state is driven by the entry box: message case +`0x43` in `ListenToElementMessage @0x004902A3` sets state `0xD` +(disabled) when the text is empty and `1` (enabled) otherwise. + +### 2.3 Recruit + +`gmFellowshipUI::RecruitFellow(guid) @0x0048E530`: + +``` +if (m_pFellowship == 0) return; // not in a fellowship +wo = ClientObjMaintSystem::GetWeenieObject(guid); +if (wo == 0 || !wo->IsPlayer()) return; // silent +if (Fellowship::IsFellow(m_pFellowship, guid)) { DisplayStringInfo(0x1A, …); return; } +CM_Fellowship::Event_Recruit(guid); // 0x00A5 +``` + +The **target comes from the world selection**, not the panel list: +button case `0xA` (`@0x004903BE`) passes +`ACCWeenieObject::selectedID`. (The panel's own list-box selection is +used for Dismiss and Leader instead.) + +The `_share_loot`-adjacent chat lines ("has given you permission to loot…") +are server-authored (`Fellowship.cs:170-177`), not client strings. + +### 2.4 Accept / decline + +Retail has **no fellowship-specific accept opcode**. The flow is: + +``` +S→C 0x0274 CharacterConfirmationRequest [u32 type=4][u32 context][str16L inviterName] + → CM_Character::DispatchUI_ConfirmationRequest @0x006A2D90 + → ClientUISystem::Handle_Character__ConfirmationRequest @0x005640A0 (switch on type) + case 4 → CM_Fellowship::SendNotice_FellowshipRequest(name, context) @0x006A6650 + → gmFellowshipUI::RecvNotice_FellowshipRequest @0x00490880 + → gmFellowshipUI::MakeFellowRequestDialog @0x00490620 + DialogFactory::MakeDialogInCurrentUI(...) + this->m_uiAcceptFellowRequestServerContextID = context; @0x004907E5 + +C→S gmFellowshipUI::RecvNotice_CloseDialog @0x0048F2C0 + → CM_Character::Event_ConfirmationResponse(4, context, response) @0x0048F3A6 + → opcode 0x0275, 0x18-byte buffer @0x006A1210 +``` + +`MakeFellowRequestDialog` **early-returns if `m_fellowRequestContext != 0`** +(`@0x00490635`) — retail shows at most **one** pending fellowship dialog. +ACE independently enforces one-per-`ConfirmationType` +(`ConfirmationManager.cs:35`). + +Server abort: `0x0276 CharacterConfirmationDone [u32 type][u32 context]` +(`CM_Character::DispatchUI_ConfirmationDone @0x006A0E20`, checks +`*(arg2) == 0x276`, reads +4 and +8). + +### 2.5 Dismiss / Quit / Disband / Leader + +All four are one-line wrappers with identical guards +(`Fellowship::IsFellow` + not-self, else a `DisplayStringInfo(0x1A, …)` +local message): + +| Function | Address | Guard | Sends | +|---|---|---|---| +| `DismissFellow(guid)` | `0x0048E470` | must be a fellow **and** not self | `0x00A4` | +| `AssignLeadershipToFellow(guid)` | `0x0048F1E0` | must be a fellow **and** not self; else literal `L"That person is not in the fellow…"` (`@0x0048F20F`) | `0x0290` | +| Quit button (case `8`) | `0x0049034B` | — | see below | +| Disband button (case `0xC`) | `0x004903EF` | — | `0x00A3` with `disband = 1` | + +**The Quit button auto-transfers leadership first** (`@0x0049034B`): + +``` +if (m_pFellowship != 0 && m_pFellowship->_leader == GetPlayerID()) { + id = Fellowship::GetNonLeaderFellowID(m_pFellowship); // @0x005B9200 + if (id != 0) AssignLeadershipToFellow(id); // sends 0x0290 +} +CM_Fellowship::Event_Quit(0); // then 0x00A3 disband=0 +``` + +So a leader pressing **Quit** emits **two** GameActions in order: +`0x0290` then `0x00A3`. ACE's own leader-quit path also reassigns +(`Fellowship.cs:340` `AssignNewLeader(null, null)` — random pick), so the +client's explicit handoff wins the race and the random pick becomes a +no-op only if the client's `0x0290` lands first. **Ordering matters** — +see §8/U4. + +### 2.6 Inbound handlers + +| S→C | Dispatcher | Handler | Effect | +|---|---|---|---| +| `0x02BE` FullUpdate | `CM_Fellowship::DispatchUI_FullUpdate @0x006A68A0` | `Handle_Fellowship__FullUpdate @0x005693C0` | allocates `CFellowship` if absent; `Fellowship::operator=`; `SendNotice_FellowshipUpdate`; on **first** update walks the table firing `SendNotice_ChangeRadarLook` per member | +| `0x02C0` UpdateFellow | `DispatchUI_UpdateFellow @0x006A6700` | `Handle_Fellowship__UpdateFellow @0x005690C0` | `wasFellow = IsFellow(guid)`; `Fellowship::UpdateFellow` (**upsert** — falls through to `AddFellow` when absent, `@0x005B976F`); if new → radar look + `SendNotice_FellowAdded`; always `SendNotice_FellowUpdated(guid, fellow, updateType)`; `SetTalkFocusEnabled(3, 1)` | +| `0x02BF` Disband | `DispatchUI_Disband @0x006A5E80` | `Handle_Fellowship__Disband @0x00569E70` | `DeleteFellowship`; `SendNotice_FellowshipDisbanded`; `SetTalkFocusEnabled(3, 0)` | +| `0x00A3` Quit | `DispatchUI_Quit @0x006A5F40` | `Handle_Fellowship__Quit @0x00569E90` | if guid != me → `RemoveFellow` + radar look; **else `DeleteFellowship`**; `SendNotice_FellowQuit`; if it was me, `SetTalkFocusEnabled(3, 0)` | +| `0x00A4` Dismiss | `DispatchUI_Dismiss @0x006A5EB0` | `Handle_Fellowship__Dismiss @0x00569F10` | byte-identical to Quit, different notice | +| `0x01C9` FellowUpdateDone | `DispatchUI_FellowUpdateDone @0x006A5F10` | **no-op** (§2.7) | — | +| `0x01CA` FellowStatsDone | `DispatchUI_FellowStatsDone @0x006A5EE0` | **no-op** (§2.7) | — | + +`SetTalkFocusEnabled(3, …)` is the chat-window fellowship-tab enable — +i.e. the `/f` channel tab lights up only while you are in a fellowship. + +**`updateType` is not branched on.** `Handle_Fellowship__UpdateFellow` +passes `arg4` straight through to `SendNotice_FellowUpdated` and always +replaces the whole `Fellow` record. Only the *notice consumers* +(`gmFellowshipUI::UpdateFellowStats` vs `UpdateFellowVitals`) care which +text they refresh. + +### 2.7 The two dead events — `0x01C9` / `0x01CA` + +Both `ClientFellowshipSystem::Handle_Fellowship__FellowUpdateDone` and +`…__FellowStatsDone` resolve to the **same address `0x004F0EA0`** in +`symbols.json`, and BN renders that address as +`DBOCache::GetCollection(…) __pure { return 0; }` — three +identical `return 0` bodies folded by MSVC's identical-COMDAT folding. + +**Conclusion: in the Sept-2013 build both events are received, validated, +their `+4` u32 is loaded, and then discarded.** ACE's own comment +("outdated message, not found in end of retail?", +`GameEventFellowshipFellowUpdateDone.cs:7`) is correct. + +> **BN zero-fold caution.** The *dispatchers* do read a u32 at `arg2+4` +> (`@0x006A5F32`, `@0x006A5F02`) and pass it. Do **not** conclude the +> messages are empty — only that nothing consumes the payload. ACE +> currently writes **zero** payload bytes +> (`GameEventFellowshipFellowUpdateDone.cs:11` is commented out), so an +> acdream parser must tolerate a 0-byte body. + +### 2.8 Button-enable rules — `gmFellowshipUI::UpdateButtons @0x0048E6C0` + +State constants: **`1` = enabled**, **`0xD` = disabled**. Two idioms +appear and both are readable once decoded: + +* `SetState(((-IsFull) & 0xC) + 1)` ⇒ `IsFull ? 0xD : 1` +* `SetState((((x == me) - 1) & 0xFFFFFFF4) + 0xD)` ⇒ `x == me ? 0xD : 1` + +Rules (`m_pFellowship != 0` throughout): + +| Button | Leader | Non-leader | +|---|---|---| +| Quit | `1` (always) | `1` (always) | +| Disband | `1` | `0xD` | +| Open | `1` | `0xD` | +| Leader | `1` iff `m_iidSelectedFellow != 0 && != me` | `0xD` | +| Dismiss | `1` iff `m_iidSelectedFellow != 0 && != me` | `0xD` | +| Recruit | selected world object is a player, not already a fellow, and `!IsFull` | **same, but only when `_open_fellow != 0`**; otherwise `0xD` | + +Open-button caption also swaps on `_open_fellow` (`@0x0048E901`). + +List-box selection (messages `3` / `0x42`, `@0x004901E5`) reads the row's +`0x1000000D` instance-ID attribute into `m_iidSelectedFellow` **and** +calls `ACCWeenieObject::SetSelectedObject` — clicking a fellow in the +panel selects them in the world (and runs the pending target-mode action +if one is armed). + +Button element IDs are `0x10000274 + n` where +`n ∈ {0: Create, 7: Leader, 8: Quit, 9: Open, 0xA: Recruit, 0xB: Dismiss, 0xC: Disband}`. + +### 2.9 The two client-side display computations + +`gmFellowshipUI::UpdateFellowStats @0x0048EB20` writes the per-row text +element `0x10000284` with two integers: the fellow's **level** and their +**XP-share percentage ×100** (via `_ftol2` at `@0x0048ECDE`). The +percentage: + +``` +if (!_share_xp) pct = 0.0f +else if (_even_xp_split) pct = FellowshipSystem::GetEvenSplitXPPctg(_currNum) // §7.2 table +else pct = GetExperienceProportion(fellow._level) + / Fellowship::CalculateExperienceProportionSum() +``` + +`GetExperienceProportion(L) = ExperienceSystem::ExperienceToRaiseLevel(L, L+1)` +(`@0x005B9B91`), and `CalculateExperienceProportionSum @0x005B9130` +returns 0 when `_share_xp == 0`, else the sum over all fellows. + +`gmFellowshipUI::UpdateFellowVitals @0x0048ED60` refreshes only the +health/stamina/mana bars for one guid. + +**These two functions are the only places the client computes anything.** +Everything else is a projection of `0x02BE`/`0x02C0`. + +### 2.10 Client-side even-split recompute (display only) + +`Fellowship::RecalculateEvenXPSplitting @0x005B92E0`, called from +`AddFellow @0x005B94D5`, `UpdateFellow @0x005B9785`, and +`RemoveFellow @0x005B96EE`: + +``` +if (_share_xp == 0) return; // leaves _even_xp_split untouched +leaderLevel = GetLeadersLevel(); // 0xFFFFFFFF if leader not in table +minLevel = 100000 (0x186A0); maxLevel = 0; +for each fellow: track min/max of _level; +_even_xp_split = 1; +if (minLevel < 50 /* 0x32 */) { + if (maxLevel > leaderLevel + 5) _even_xp_split = 0; + if (minLevel + 5 < leaderLevel) _even_xp_split = 0; +} +``` + +In English: **even split if every member is level 50+, or if every member +is within ±5 levels of the leader.** The retail client has **no** +"within 10 ⇒ proportional" tier — that decision is server-only, and the +client just receives `_share_xp = 0` when the spread is too wide. + +### 2.11 Lock / departed + +`Fellowship::HandleLockedRemoveFellow @0x005B9270` — when `_locked`, +stamps `Timer::get_real_time()` into `_fellows_departed[guid]` +(add, or remove-then-add if already present). + +`Fellowship::AddFellow @0x005B9480` — when `_locked`, admits a guid only +if `_fellows_departed[guid]` exists **and** +`get_real_time() - departedTime <= 0x384` (**900 seconds = 15 minutes**, +`@0x005B94A5`). + +### 2.12 Character-option accessors (all four, byte-exact) + +| Option | `PlayerOption` id | Word | Bit | Mask | Accessor | Auto-save? | +|---|---|---|---|---|---|---| +| `IgnoreFellowshipRequests` | `0x02` | `options_` | 3 | `0x00000008` | `@0x005D2B30` / set `@0x005D2B40` | **yes** | +| `FellowshipShareXP` | `0x0F` | `options_` | 18 | `0x00040000` | `@0x005D2F30` / set `@0x005D2F40` | **yes** | +| `FellowshipShareLoot` | `0x11` | `options_` | 20 | `0x00100000` | `@0x005D2FD0` / set `@0x005D2FE0` | **yes** | +| `FellowshipAutoAcceptRequests` | `0x12` | `options_` | 29 | `0x20000000` | `@0x005D3020` / set `@0x005D3030` | **yes** | + +(`AcceptLootPermits 0x10` sits between them and is also auto-save; ids +`0x0F`–`0x12` are all in the 21-entry `IsAutoSaveOption` table pinned in +`2026-08-10-character-options-map.md` §1.3.) All four therefore ride +**`0x0005 SetSingleCharacterOption` immediately**, never the batched +`0x01A1` blob. + +Mutual exclusion, `CPlayerModule::OnChanged @0x0059A8E0`: + +``` +case 2: if (IgnoreFellowshipRequests()) SetFellowshipAutoAcceptRequests(0); +case 0x12: if (FellowshipAutoAcceptRequests()) SetIgnoreFellowshipRequests(0); +``` + +Because the clear goes through the real accessor, the **cleared** option's +own `0x0005` is emitted *before* the primary option's — acdream already +models this exactly (`RuntimeCharacterState.cs:821-834`, MF-2). + +--- + +## 3. Wire field order — the golden-byte-vector source + +**Envelope conventions.** C→S GameAction body = +`[u32 0xF7B1][u32 orderedSequence][u32 subOpcode][payload]`; S→C GameEvent +body = `[u32 0xF7B0][u32 targetGuid][u32 eventSequence][u32 eventType][payload]`. +Both are already implemented in acdream +(`SocialActions.cs:28`, `GameEventEnvelope.cs`) and are not re-derived here. + +**`str16L`** = `[u16 byteLength][bytes…][pad to a 4-byte multiple counting the 2 length bytes]`, +Windows-1252. Confirmed three ways: retail +`AC1Legacy::PStringBase<char>::Pack`, ACE +`BinaryReaderExtensions.cs:41-49` (comment: *"client pads string length to +be a multiple of 4 including the 2 bytes for length"*), holtburger +`messages/utils.rs`. + +**`PackableHashTable<K,V>` header** = one u32 written as +`(tableSize << 16) | currNum` (`PackableHashTable<ulong,Fellow>::Pack @0x005699C9`), +i.e. on the wire **`[u16 count][u16 numBuckets]`** little-endian, followed +by `count` × `[K key][V value]`. Confirmed by ACE +`PackableHashTable.cs:14-18` and holtburger +`messages/utils.rs:94-107`. Retail's UnPack rejects +`tableSize > 0x10000` or `count > 0x10000` (`@0x00569B1B`). + +Bucket counts on the wire (**server-chosen; the client does not care**): +fellows = **16** (ACE `GameEventFellowshipFullUpdate.cs:11`, holtburger +`FELLOW_BUCKETS`), departed = **32** (ACE `Fellowship.cs:784`, holtburger +`FELLOWSHIP_LOCK_BUCKETS`), locks = **32**. + +### 3.1 `0x00A2` FellowshipCreate — C→S + +Retail builder `CM_Fellowship::Event_Create @0x006A67A0`; buffer size +`strPackSize + 0x10`. + +| Off | Type | Field | Source | +|---|---|---|---| +| +0 | u32 | `0x000000A2` | `@0x006A6809` | +| +4 | str16L | fellowship name (post-`ACCharGenData::FormatName`) | `@0x006A6828` | +| +N | u32 | `shareXP` (0/1, from `PlayerOption 0x0F`) | `@0x006A6835` | + +ACE: `GameActionFellowshipCreate.cs:11-12` — `ReadString16L()` then +`ReadUInt32() > 0`. holtburger: `actions.rs:11-20` — identical. +Chorizite: `Fellowship_Create.generated.cs:30-31` — identical +(`ReadBool()` there is a 4-byte read, `BinaryReaderExtensions.cs:178-181`). +**All four agree. There is no `openness` field.** + +### 3.2 `0x00A3` FellowshipQuit — C→S + +`CM_Fellowship::Event_Quit @0x006A61E0`, buffer `0x10`. + +| Off | Type | Field | +|---|---|---| +| +0 | u32 | `0x000000A3` | +| +4 | u32 | `disband` (0 = quit, 1 = disband) | + +ACE `GameActionFellowshipQuit.cs:9`; holtburger `actions.rs:59-68`. + +### 3.3 `0x00A4` FellowshipDismiss — C→S + +`Event_Dismiss @0x006A6110`, buffer `0x10`: `[u32 0xA4][u32 targetGuid]`. +ACE `GameActionFellowshipDismiss.cs:8`; holtburger `actions.rs:81-92`. + +### 3.4 `0x00A5` FellowshipRecruit — C→S + +`Event_Recruit @0x006A62B0`, buffer `0x10`: `[u32 0xA5][u32 targetGuid]`. +ACE `GameActionFellowshipRecruit.cs:10`; holtburger `actions.rs:35-46`. + +### 3.5 `0x00A6` FellowshipUpdateRequest — C→S + +`Event_UpdateRequest @0x006A6380`, buffer `0x10`: +`[u32 0xA6][u32 panelOpen]`. Sent from +`gmFellowshipUI::OnVisibilityChanged @0x0048E460` — **the argument is the +panel's visibility, not fellowship openness.** ACE +`GameActionFellowshipUpdateRequest.cs:11` (comment: *"indicates if +fellowship panel on client is visible"*); holtburger +`FellowshipUpdateRequestActionData.panel_open`. + +### 3.6 `0x0290` FellowshipAssignNewLeader — C→S + +`Event_AssignNewLeader @0x006A5F70`, buffer `0x10`: +`[u32 0x290][u32 newLeaderGuid]`. ACE +`GameActionFellowshipAssignNewLeader.cs:8`; holtburger `actions.rs:105-116`; +Chorizite `protocol.xml:417`. + +### 3.7 `0x0291` FellowshipChangeOpenness — C→S + +`Event_ChangeFellowOpeness @0x006A6040`, buffer `0x10`: +`[u32 0x291][u32 isOpen]`. ACE +`GameActionFellowshipChangeOpenness.cs:9`; Chorizite `protocol.xml:418`. + +Note the client **pre-toggles its own `_open_fellow` before sending** +(`@0x0049038A`) and calls `UpdateButtons` immediately — optimistic UI. If +ACE refuses (locked fellowship, `Player_Fellowship.cs:46`) the client's +local flag is wrong until the next `0x02BE`. + +### 3.8 `Fellow` — the packed member record (**the load-bearing layout**) + +From `Fellow::Pack @0x005B9A10` and `Fellow::UnPack @0x005B9AD0`. +`Fellow::GetPackSize @0x005B99F0` returns `0x28 + strPackSize` — 0x28 = +40 = exactly ten u32s. + +| Off | Type | Field | Pack site | +|---|---|---|---| +| +0x00 | u32 | `_cp_cache` (undistributed XP) | `@0x005B9A31` | +| +0x04 | u32 | `_lum_cache` (undistributed luminance) | `@0x005B9A3F` | +| +0x08 | u32 | `_level` | `@0x005B9A4B` | +| +0x0C | u32 | `_max_health` | `@0x005B9A59` | +| +0x10 | u32 | `_max_stamina` | `@0x005B9A65` | +| +0x14 | u32 | `_max_mana` | `@0x005B9A73` | +| +0x18 | u32 | `_current_health` | `@0x005B9A7F` | +| +0x1C | u32 | `_current_stamina` | `@0x005B9A8D` | +| +0x20 | u32 | `_current_mana` | `@0x005B9A99` | +| +0x24 | u32 | `_share_loot` | `@0x005B9AA7` | +| +0x28 | str16L | `_name` | `@0x005B9AB5` | + +**The GUID is not part of `Fellow`** — inside `0x02BE` it is the +hash-table key written immediately before the value +(`PackableHashTable<…>::Pack @0x005699F9`), and inside `0x02C0` it is an +explicit leading u32 (§3.10). Either way, on the wire the sequence is +always `[u32 guid][40 bytes][str16L name]` = **44 fixed bytes + name**, +exactly holtburger's `FellowshipMemberData::unpack` +(`events.rs:44-78`, `*offset + 44`). + +`_share_loot` is a **raw u32 that the client stores verbatim** +(`@0x005B9B5C`) and never interprets — see §4.1 for why that matters. + +### 3.9 `0x02BE` FellowshipFullUpdate — S→C + +Body = `Fellowship::Pack`. Retail reads it back at +`Fellowship::UnPack @0x005B94F0` in exactly this order: + +| # | Type | Field | UnPack site | +|---|---|---|---| +| 1 | `PackableHashTable<u32, Fellow>` | members — `[u16 count][u16 buckets=16]` then `count` × (`[u32 guid]` + §3.8) | `@0x005B951D` | +| 2 | str16L | fellowship name | `@0x005B9544` | +| 3 | u32 | leader guid | `@0x005B9559` | +| 4 | u32 | `_share_xp` | `@0x005B957E` | +| 5 | u32 | `_even_xp_split` | `@0x005B957E` | +| 6 | u32 | `_open_fellow` | `@0x005B957E` | +| 7 | u32 | `_locked` | `@0x005B957E` | +| 8 | `PackableHashTable<u32, i32>` | departed — `[u16 count][u16 buckets=32]` then `count` × `[u32 guid][i32 timestamp]` | `@0x005B95D6` | +| **9** | `PackableHashTable<str16L, FellowshipLockData>` | **locks — sent by the server, NOT read by the 2013 client** | — | + +Field 9 exists on the wire: ACE writes it (`GameEventFellowshipFullUpdate.cs:42`), +Chorizite's pcap-derived schema has it +(`Types/Fellowship.generated.cs:78`), and holtburger parses it +(`events.rs:233-237`). `FellowshipLockData` = five u32s +(`unknown1, unknown2, unknown3, timestamp, sequence`) — Chorizite +`FellowshipLockData.generated.cs:42-48`, holtburger `events.rs:139-160`. + +**`Fellowship::UnPack` stops after field 8** and returns success; retail +simply ignores the trailing lock table. An acdream parser should read all +nine (the GameEvent framing gives the length anyway) so the locks are +available for §2.11 display, but must **not** fail when field 9 is absent. + +### 3.10 `0x02C0` FellowshipUpdateFellow — S→C + +`CM_Fellowship::DispatchUI_UpdateFellow @0x006A6700`: + +| Off | Type | Field | Site | +|---|---|---|---| +| +0 | u32 | `0x02C0` | `@0x006A6726` | +| +4 | u32 | **member guid** | `@0x006A673B` | +| +8 | 40 B + str16L | `Fellow` (§3.8) | `@0x006A6763` | +| … | u32 | `updateType` | `@0x006A676C` | + +`updateType` values (Chorizite `FellowUpdateType.generated.cs:18-22`, +holtburger `events.rs:14-19`, ACE `FellowUpdateType`): +`0 Undef` / `1 Full` / `2 UpdateStats` / `3 UpdateVitals`. +ACE only ever sends `1` (`Fellowship.cs:166`, default parameter) and +`3` (`Fellowship.cs:724`). + +> **Reference disagreement, resolved.** Chorizite's generated +> `Fellowship_UpdateFellow` is **missing the leading guid** +> (`protocol.xml:7807-7810` declares only `Fellow` + `FellowUpdateType`, +> and `Types/Fellow.generated.cs:82` starts at `XPCached`). Retail +> (`@0x006A673B`) and ACE (`GameEventFellowshipUpdateFellow.cs:13`) and +> holtburger (`events.rs:49`) all put the guid first. **Retail wins — +> use guid-first.** + +### 3.11 `0x02BF` FellowshipDisband — S→C + +`[u32 0x02BF]` and nothing else. `DispatchUI_Disband @0x006A5E80` reads +only the opcode; ACE `GameEventFellowshipDisband.cs:5-9` writes no body. + +### 3.12 `0x00A3` FellowshipQuit — S→C + +`[u32 0x00A3][u32 quitterGuid]`. `DispatchUI_Quit @0x006A5F5F` reads +`arg2+4`. ACE `GameEventFellowshipQuit.cs:8`. Sent to the quitter +(`Fellowship.cs:325`) **and** to every remaining member +(`Fellowship.cs:332`) — the client's handler distinguishes by comparing +the guid to `SmartBox::smartbox->player_id`. + +### 3.13 `0x00A4` FellowshipDismiss — S→C + +`[u32 0x00A4][u32 dismissedGuid]`. `DispatchUI_Dismiss @0x006A5EC9`. +ACE `GameEventFellowshipDismiss.cs:11` (its comment *"can be both S2C and +C2S?"* is correct — the same numeric value is used in both directions, +in different envelopes). + +### 3.14 `0x01C9` / `0x01CA` — S→C, dead + +`[u32 opcode][u32 payload]` per the dispatchers, but see §2.7: the client +discards them and ACE writes a zero-length body. **Parse-and-ignore.** + +### 3.15 Confirmation triple (shared, `ConfirmationType = 4`) + +| Dir | Opcode | Layout | Anchors | +|---|---|---|---| +| S→C | `0x0274` | `[u32 type][u32 context][str16L text]` | `DispatchUI_ConfirmationRequest @0x006A2D90` (reads +4, +8, then `PStringBase::UnPack` from +0xC); ACE `GameEventConfirmationRequest.cs:10-12` | +| C→S | `0x0275` | `[u32 type][u32 context][u32 response]` | `Event_ConfirmationResponse @0x006A1210` (buffer `0x18`; writes `@0x006A126A`, `@0x006A127B`, `@0x006A12B5`, `@0x006A12F3`); ACE `GameActionConfirmationResponse.cs:11-13` | +| S→C | `0x0276` | `[u32 type][u32 context]` | `DispatchUI_ConfirmationDone @0x006A0E4A`; ACE `GameEventConfirmationDone.cs` | + +`ConfirmationType` values, retail +`Handle_Character__ConfirmationRequest @0x005640A0` switch vs ACE +`ConfirmationType.cs:5-12` — **identical**: `1 SwearAllegiance`, +`2 AlterSkill`, `3 AlterAttribute`, **`4 Fellowship`**, `5 CraftInteraction`, +`6 Augmentation`, `7 Yes_No`. + +### 3.16 `0x0005` SetSingleCharacterOption (the four option bits) + +`[u32 0x0005][u32 playerOptionId][u32 value]` — already shipped in acdream +(`SocialActions.cs:56`, `WorldSession.SendSetSingleCharacterOption`). +Ids `0x02`, `0x0F`, `0x11`, `0x12` per §2.12. + +--- + +## 4. ACE caveats — where the server diverges or does not serve + +Every claim here is pinned to an ACE source line. **Do not assume ACE +matches retail; these are the places it does not.** + +### 4.1 `shareLoot` is encoded two different ways, and neither is 1 + +| Message | ACE writes | Line | +|---|---|---| +| `0x02BE` FullUpdate, per fellow | **constant `0x10`** (comment: *"if 0 then noSharePhatLoot, if 16 (0x0010) then sharePhatLoot"*) — the fellow's actual permission is never consulted | `GameEventFellowshipFullUpdate.cs:63` | +| `0x02C0` UpdateFellow | `Convert.ToUInt32(shareLoot) << 1` ⇒ **0 or 2** | `GameEventFellowshipUpdateFellow.cs:25` | + +So ACE reports `0x10` for **every** fellow in a full update regardless of +setting, and `0`/`2` in incremental updates. Chorizite's `Fellow.ShareLoot` +is `ReadBool()` = `ReadInt32() == 1` +(`BinaryReaderExtensions.cs:178-181`), which evaluates **both** ACE +encodings to `false`. + +Retail stores the raw u32 verbatim (`@0x005B9B5C`) and — in the 2013 +build — never reads `_share_loot` for any decision; +`Fellowship::UpdateFellow`/`operator=` only copy it +(`@0x005B9923`, `@0x005B99B6`). + +**Recommendation for acdream: model `shareLoot` as a raw `uint`, and +treat `!= 0` as "shares".** Never `== 1`. Never round-trip it as a bool. +File the ACE inconsistency; it is the kind of thing a future +golden-byte-vector test will trip over. + +### 4.2 Fellowship-level truth ACE does serve + +`ShareXP`, `EvenShare`, `Open`, `IsLocked`, `DepartedMembers`, +`FellowshipLocks` are all real and all written +(`GameEventFellowshipFullUpdate.cs:32-42`). `cpCached` and `lumCached` are +**hard-coded 0 with a TODO** in both messages +(`GameEventFullUpdate.cs:49-50`, `GameEventUpdateFellow.cs:16-17`) — so +those two panel columns will read zero against ACE forever, which is a +*server* gap, not an acdream one. + +### 4.3 The even-split share table — ACE's 9-fellow row is wrong + +| fellows | retail (byte-decoded, §7.2) | retail total multiplier | ACE `GetMemberSharePercent()` (`Fellowship.cs:604-632`) | +|---:|---|---|---| +| 1 | 1.0 | 1.00 | 1.0 ✔ | +| 2 | 0.75 | 1.50 | .75 ✔ | +| 3 | 0.6 | 1.80 | .6 ✔ | +| 4 | 0.55 | 2.20 | .55 ✔ | +| 5 | 0.5 | 2.50 | .5 ✔ | +| 6 | 0.45 | 2.70 | .45 ✔ | +| 7 | 0.4 | 2.80 | .4 ✔ | +| 8 | 0.35 | 2.80 | .35 ✔ | +| **9** | **0.3111111** | **2.80** | **.3** ✘ (total 2.70) | +| 10 | 0.28 | 2.80 | *(no case — falls through to 1.0)* ✘ | +| 0 or >10 | **0.0** | — | 1.0 ✘ | + +Retail's design is transparent once the totals are written out: **the +fellowship XP multiplier caps at exactly 2.8× from 7 members up.** ACE's +`.3` breaks that cap at 9 (a 3.6 % XP shortfall for a full fellowship), +and its out-of-range default of `1.0` is the opposite of retail's `0.0`. + +This is a **server** bug. It does not block acdream, but the client's own +panel percentage (§2.9) will disagree with the XP the server actually +grants — so any parity gate that compares "panel says 31.1 %" against +"ACE granted 30 %" is measuring ACE, not us. + +### 4.4 Locked-fellowship re-recruit window: 900 s vs 600 s + +Retail: `Fellowship::AddFellow @0x005B94A5` — `<= 0x384` = **900 s**. +ACE: `Fellowship.cs:93` — `AddSeconds(600)`. +ACE's own broadcast text says **15 minutes** +(`Fellowship.cs:436`: *"If you leave the fellowship, you have 15 minutes +to be recruited back into the fellowship."*). ACE's message and ACE's code +disagree; retail agrees with ACE's message. + +Client-side impact is nil (the server decides recruitment), but if acdream +ever renders a countdown from `_fellows_departed`, use **900**. + +### 4.5 No panel-open request ⇒ no vitals + +`Fellowship.OnVitalUpdate` only sends `0x02C0` to fellows whose +`FellowshipPanelOpen` is true (`Fellowship.cs:723`), and that flag is set +**only** by `0x00A6` (`Player_Fellowship.cs:142-148`). An acdream client +that never sends `0x00A6` will see a static roster: correct names, levels +and max-vitals from `0x02BE`, but current health/stamina/mana frozen at +join time. **This makes feature 12 a prerequisite for feature 13, not a +nicety.** + +The upstream driver is the `FellowVitalUpdate` latch consumed once per +player tick (`Player_Tick.cs:84-89`), so the cadence is per-tick-coalesced, +not per-damage-event. + +### 4.6 Things ACE does not implement or does not serve + +| Gap | Evidence | +|---|---| +| **No fellowship-name validation.** `FellowshipNameIsNotPermitted (0x041F)` is defined but never sent. | `grep FellowshipNameIsNotPermitted` over `ACE.Server` returns only the enum. Retail formats the name client-side via `ACCharGenData::FormatName` (§2.2), so retail may have relied on that plus a server check we cannot see. | +| **No client-driven lock/unlock.** `HandleActionFellowshipChangeLock` exists (`Player_Fellowship.cs:50-54`) but has **no `GameAction` mapped to it** — only `EmoteManager` reaches it. ACE's own comment: *"Unlocking a fellowship is not possible without disbanding in retail worlds"* (`Fellowship.cs:420`). | Retail also has no lock button in `gmFellowshipUI` (§2.1 control list). Consistent. | +| **`FellowshipMaxDistanceExceeded (0x0419)`, `FellowshipSquelched (0x0418)`, `FellowshipIllegalLevel (0x041B)`, `FellowshipMember (0x041A)`, `FellowshipTimeout (0x04DC)` are never sent.** Recruit-busy is a plain chat line, not `FellowshipRecruitBusy (0x041C)` (`Fellowship.cs:117`). | `Fellowship.cs` / `Player_Fellowship.cs` send only `FellowshipIgnoringRequests`, `YouMustBeLeaderOfFellowship`, `YourFellowshipIsFull`, `FellowshipDeclined`, `FellowshipIsLocked`, `OlthoiCannotJoinFellowship`, `LockedFellowshipCannotRecruit_`, `_IsNowOpenFellowship`/`_IsNowClosedFellowship`/`_IsNowLeaderOfFellowship`/`YouHavePassedFellowshipLeadershipTo_`. | +| **Leader-drop reassignment is random.** `AssignNewLeader(null, null)` picks `ThreadSafeRandom` over the member list (`Fellowship.cs:396-400`). Retail's client pre-empts this by sending `0x0290` on Quit (§2.5); on a *crash* disconnect it does not. | — | +| **A logoff is a permanent quit.** `LogOut_Inner` calls `FellowshipQuit(false)` (`Player.cs:531-532`) — you are not re-added on login. `IsLocked` gives a 10-minute (ACE) / 15-minute (retail) re-recruit grace, nothing more. | — | +| **`GetFellowshipMembers()` silently drops offline members** and logs `"Dropped fellow: …"`, then reassigns leadership and broadcasts a full update (`Fellowship.cs:739-779`). Expect unsolicited `0x02BE` bursts. | — | +| **Proximity for luminance uses radar range, for XP uses 600/1200 m.** Two different distance models in the same class. | `Fellowship.cs:592` (`WithinRange`, 75 m outdoor / 25 m indoor, `Player.cs:73-78`) vs `Fellowship.cs:634-668` (`MaxDistance = 600`). `fellow_kt_landblock` (default **false**, `PropertyManager.cs:554`) switches `WithinRange` to a 192 m / same-landblock rule. | +| **Config knobs that change observable behaviour**: `fellow_busy_no_recruit` (default **true**, "different from retail"), `fellow_quest_bonus` (default **false**), `fellowship_even_share_level` (default **50** — matches retail's hard-coded `0x32`). | `PropertyManager.cs:552,555,623` | + +--- + +## 5. acdream today + +### 5.1 The complete inventory + +| Area | File:line | State | +|---|---|---| +| S→C opcode enum (all 7) | `src/AcDream.Core.Net/Messages/GameEventType.cs:29,30,56,57,100,101,102` | **enum-only** | +| C→S opcode constants (5 of 7) | `src/AcDream.Core.Net/Messages/SocialActions.cs:37-41` | **builder-only**; `0x0290`/`0x0291` **absent** | +| Outbound builders | `SocialActions.cs:123,141,152,156,160` | **no production caller** — `grep BuildFellowship` finds only the definitions and `tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs:53-105` | +| Inbound parsing | — | **none.** `GameEventWiring.WireAll` registers ~40 event types; **zero** fellowship | +| Fellowship state class | — | **none.** (`src/AcDream.Core/Social/` holds only `FriendsState.cs` + `SquelchState.cs` — the precedent shape) | +| Option `IgnoreFellowshipRequests` | `CharacterOptionTable.cs:113` (`0x02`, `0x00000008`, autoSave, default **on**) | **live** storage + wire; store-only UI | +| Option `FellowshipShareXP` | `CharacterOptionTable.cs:126` (`0x0F`, `0x00040000`, autoSave, default **on**) | same | +| Option `FellowshipShareLoot` | `CharacterOptionTable.cs:128` (`0x11`, `0x00100000`, autoSave, default off) | same | +| Option `FellowshipAutoAcceptRequests` | `CharacterOptionTable.cs:129` (`0x12`, `0x20000000`, autoSave, default off) | same | +| Option mutual exclusion (MF-2) | `RuntimeCharacterState.cs:794-843` | **live and retail-exact** | +| Fellowship **chat** | `ChatInputParser.cs:87-93`, `ChannelResolver.cs:39`, `LegacyChannelChatType.cs:78`, `RetailChatColorTable` `0x13` | **LIVE** | +| Confirmation transport | `GameEventWiring.cs:202,212` (`0x0274`, `0x0276`), `GameplayConfirmationController.cs:37` | `0x0274`/`0x0276` parsed; **no type-4 branch** | +| WeenieError enum | `src/AcDream.Core/Physics/WeenieError.cs:309-317,453-454,485-501,553` | **complete** for fellowship | +| WeenieError display text | `src/AcDream.Core/Chat/WeenieErrorMessages.cs:257-259,446-450,465-466,480,596` | **11 of 19 present.** Missing: `0x0417`, `0x0418`, `0x0419`, `0x041A`, `0x041B`, `0x041C`, `0x04DB`, `0x04DC` — including **`0x0417 FellowshipIgnoringRequests` and `0x04DB FellowshipDeclined`, both of which ACE actually sends** (`Player_Fellowship.cs:101`, `Fellowship.cs:148`) | +| Radar seam | `RadarBlipColors.cs:58-61,168-171`, `RetailRadar.cs:30-31,107-111` | seam exists, `IsFellowshipMember`/`IsFellowshipLeader` hard-wired `false` — **AP-90** | +| Input actions | `InputAction.cs:86,88,106`; `KeyBindings.cs:201,202,210` (N / M / F4); `RetailActionIdentityTable.cs:186,187,194` | declared, bound, **no consumers** | +| Panel | — | **none.** `docs/research/retail-ui/05-panels.md:702-760` has the §9 design sketch | + +### 5.2 The two builder defects (both currently latent — no callers) + +**(a) `BuildFellowshipCreate` invents a field and sends the wrong flag.** +`SocialActions.cs:123-138` writes the name, then **two single bytes** +`openness` and `shareXp`: + +```csharp +body[12 + name.Length] = openness ? (byte)1 : (byte)0; +body[12 + name.Length + 1] = shareXp ? (byte)1 : (byte)0; +``` + +Retail/ACE/holtburger/Chorizite all expect exactly one trailing `u32 +shareXP` (§3.1). Because the padding arithmetic happens to produce the +same total length, the message is *accepted* by ACE — it just reads the +`openness` byte as the low byte of `shareXP`. With +`(openness: true, shareXp: false)`, ACE stores `ShareXP = true`. +**Silently inverted, not rejected.** The test at +`SocialActionsTests.cs:53-68` pins the wrong shape +(`Assert.Equal(1, body[20]); Assert.Equal(0, body[21]);`) and must be +rewritten alongside the fix. + +The `// string16L name, bool openness, bool shareXP` comment at +`SocialActions.cs:37` is the origin of the error. + +**(b) `BuildFellowshipUpdate(uint seq, bool open)` names the wrong +feature.** `0x00A6` is `FellowshipUpdateRequest` — *panel visibility* +(§3.5). Openness is `0x0291`, which acdream does not have. The comment at +`SocialActions.cs:41` (`// bool open`) and the method name are both wrong; +the *bytes* are correct for a panel-visibility request. Test +`SocialActionsTests.cs:98-105` pins the byte, not the meaning, so it +survives a rename. + +Nothing else is wrong: `Quit`, `Dismiss`, `Recruit` are byte-correct +(the 1-byte writes into zeroed 16-byte buffers produce the same LE u32 as +retail's full-word writes). + +--- + +## 6. Implement vs store — recommendation + +### 6.1 What to implement (in dependency order) + +1. **`FellowshipState` in `AcDream.Core/Social/`** — mirror + `FriendsState.cs`. Fields exactly as §3.9, members keyed by guid with + the §3.8 record. `shareLoot` as raw `uint`, not `bool` (§4.1). +2. **Parsers + `GameEventWiring` registrations** for `0x02BE`, `0x02C0`, + `0x02BF`, `0x00A3`, `0x00A4`; parse-and-ignore `0x01C9`/`0x01CA`. + The dispatcher seam already exists — `GameEventWiring.WireAll` + (`src/AcDream.Core.Net/GameEventWiring.cs`). +3. **Fix the two builders** (§5.2) and add `0x0290` + `0x0291`. +4. **Confirmation type-4 branch** — smallest user-visible slice; the + `0x0274`/`0x0275`/`0x0276` transport is already shipped. Must consult + `FellowshipAutoAcceptRequests` so no popup appears when ACE + auto-accepted (§1, feature 6). +5. **`0x00A6` on panel show/hide** — a prerequisite for live vitals (§4.5). +6. **The panel** (`gmFellowshipUI`), with the §2.8 button rules and the + §2.9 percentage text. +7. **Radar markers** (AP-90) and the N/M/F4 input consumers — both are + pure consumers of step 1 with no new mechanism. +8. **The 8 missing WeenieError strings** (§5.1) — `0x0417` and `0x04DB` + are on ACE's real send paths, so they are user-visible gaps today. + +### 6.2 What to store, not compute + +* **`_even_xp_split` and `_share_xp`**: take the wire value. The retail + client's `RecalculateEvenXPSplitting` (§2.10) is a *local optimistic + recompute for display between updates*, and it deliberately implements + only two of the server's three tiers. Re-deriving it as authority would + make acdream disagree with ACE whenever the leader's level is between + 5 and 10 above the lowest fellow. **Port it, if at all, only as the + display-smoothing it is — and never let it override a `0x02BE`.** +* **Distance/proximity scalars (§4.6)**: entirely server-side. There is + no retail client function for them. Do not model. +* **The even-split share table (§7.2)**: needed *only* for the panel's + per-fellow percentage text. It is not an XP calculation. +* **`IsFull` (`>= 9`)**: client-side button gating only. The server + re-checks twice (`Fellowship.cs:102`, `:151`). +* **The four character options**: already correct. ACE is the consumer of + all four; acdream's only job is to send the bit, which it does. They + stay AD-78-dimmed until/unless the fellowship panel gives + `FellowshipShareXP` a *client-visible* consumer (the Create flow reads + it — see the un-dim note in §8/U6). + +--- + +## 7. BN zero-fold and decomp-artifact warnings + +Per the lane brief: **flag literal-0 operands hiding real values rather +than asserting them.** Three appear in this feature; two are resolved by +byte-decode below, one is resolved by reading the caller. + +### 7.1 `Fellowship::IsFull @0x005B9070` — BN says `return 1` + +BN renders the body as `return ((eax - eax) + 1);` — i.e. constant 1. +acclient.c (the second decompiler) fares no better +(`acclient.c:835878`, garbage). **Both are wrong: it is a folded `sbb` +idiom.** Byte-decoded from the PDB-paired binary at file offset +`0x1B9070` (VA − ImageBase, `.text` VA `0x00401000` / raw `0x1000`): + +``` +83 79 14 09 cmp dword ptr [ecx+0x14], 9 +1b c0 sbb eax, eax +40 inc eax +c3 ret +``` + +`[ecx+0x14]` is `Fellowship._fellowship_table._currNum` +(`Fellowship` = vtable +0, then `PackableHashTable` at +4 whose +`_currNum` sits at +0x14). `sbb eax,eax` yields −1 when the borrow flag +is set (`_currNum < 9`), 0 otherwise; `inc eax` maps that to 0/1. + +**`IsFull() ⇔ _currNum >= 9`. Max fellowship = 9, byte-verified.** + +### 7.2 `FellowshipSystem::GetEvenSplitXPPctg @0x005B9BA0` — BN says every case returns `arg1 - 1` + +BN renders all ten switch cases as `return (arg1 - 1);` and types the +return `int32_t`. **Both are wrong.** The real function returns a +**float on the x87 stack** loaded from `.rdata`; BN elided the `fld` +targets. Byte-decoded: + +``` +8b 44 24 04 mov eax, [esp+4] ; numFellows +48 dec eax +83 f8 09 cmp eax, 9 +77 4d ja default ; → 0x005B9BF7 +ff 24 85 00 9c 5b 00 jmp dword ptr [eax*4 + 0x005B9C00] +… +005B9BB1: d9 05 b0 28 79 00 c3 fld dword [0x007928B0]; ret ; n=1 +005B9BB8: d9 05 dc b6 79 00 c3 fld dword [0x0079B6DC]; ret ; n=2 +005B9BBF: d9 05 c4 48 79 00 c3 fld dword [0x007948C4]; ret ; n=3 +005B9BC6: d9 05 c0 72 7e 00 c3 fld dword [0x007E72C0]; ret ; n=4 +005B9BCD: d9 05 b8 28 79 00 c3 fld dword [0x007928B8]; ret ; n=5 +005B9BD4: d9 05 d4 91 7c 00 c3 fld dword [0x007C91D4]; ret ; n=6 +005B9BDB: d9 05 f0 a9 7c 00 c3 fld dword [0x007CA9F0]; ret ; n=7 +005B9BE2: d9 05 bc 72 7e 00 c3 fld dword [0x007E72BC]; ret ; n=8 +005B9BE9: d9 05 b8 72 7e 00 c3 fld dword [0x007E72B8]; ret ; n=9 +005B9BF0: d9 05 b4 72 7e 00 c3 fld dword [0x007E72B4]; ret ; n=10 +005B9BF7: d9 05 44 53 79 00 c3 fld dword [0x00795344]; ret ; default +005B9C00: jump table — b1/b8/bf/c6/cd/d4/db/e2/e9/f0 9b 5b 00 (10 entries) +``` + +Section map used for VA→file-offset (`.rdata` VA `0x00792000` / raw +`0x00392000`), floats read directly: + +| n | VA | float | +|---:|---|---| +| 1 | `0x007928B0` | **1.0** | +| 2 | `0x0079B6DC` | **0.75** | +| 3 | `0x007948C4` | **0.6** | +| 4 | `0x007E72C0` | **0.55** | +| 5 | `0x007928B8` | **0.5** | +| 6 | `0x007C91D4` | **0.45** | +| 7 | `0x007CA9F0` | **0.4** | +| 8 | `0x007E72BC` | **0.35** | +| 9 | `0x007E72B8` | **0.3111111** | +| 10 | `0x007E72B4` | **0.28** | +| default (0 or >10) | `0x00795344` | **0.0** | + +Used live at `gmFellowshipUI::UpdateFellowStats @0x0048EC2C` — this is a +**shipped display path**, not dead code. + +### 7.3 The `SetState` folds in `UpdateButtons` + +`SetState((((eax - eax) & 0xc) + 1))` and +`SetState((((ebx - 1) & 0xfffffff4) + 0xd))` are the same `sbb`/`setcc` +folding class. Decoded in §2.8: they are `cond ? 0xD : 1`. **Do not read +these as constants.** + +### 7.4 Non-folds that still need care + +* `Fellowship::GetLeadersLevel @0x005B91B0` genuinely returns + `0xFFFFFFFF` when the leader is not in the member table — that is a + real sentinel, not an artifact. `RecalculateEvenXPSplitting` then + compares `maxLevel > 0xFFFFFFFF + 5` in unsigned arithmetic; treat a + leaderless table as "leave `_even_xp_split` at 1". +* `PackableHashData<K,V>` field offsets in the pseudo-C appear as raw + indices (`i[0xd]` = `_next`, `i[0xe]` = `_hashVal`, `+0xc` = `_level`). + These are correct but unnamed — cross-check against + `acclient.h:36663-36670` before trusting any new offset. +* `HandleLockedRemoveFellow @0x005B9270` shows BN calling three + *differently-typed* `PackableHashTable` template instantiations + (`<u32,u32>::lookup`, `<IDClass,int>::add`, `<u32,int>::remove`) on the + same member. That is COMDAT folding of identical template code, not + three different tables. + +--- + +## 8. Unknowns for the coordinator + +**U1 — `_fellows_departed` timestamp epoch.** Retail's client writes +`Timer::get_real_time()` (`@0x005B929D`) and compares against it +(`@0x005B94A5`), while ACE writes `Time.GetUnixTime()` +(`Fellowship.cs:318`). Since the client's whole table is overwritten by +every `0x02BE` (`Fellowship::operator=`), the client would be comparing +server Unix seconds against its own clock — which would make its local +`AddFellow` gate meaningless. **Unresolved:** whether +`Timer::get_real_time()` is wall-clock or session-relative. Consequence +is confined to rendering a departed-member countdown; it does **not** +affect any wire field. Resolve by cdb-tracing `Timer::get_real_time` +against a known wall clock, or by declining to render a countdown. + +**U2 — the layout DAT id for `gmFellowshipUI`.** Not found in +`docs/research/retail-ui/` (which has the panel's *content* at +`05-panels.md:702-760` but no `LayoutDesc` id) and not derivable from the +decomp alone. **The element ids are known** (`0x10000274 + n` for buttons, +`0x1000000D` for the row instance-ID attribute, `0x10000284` for the +per-row stats text, `0x10000001` for the string table) — but the panel's +own LayoutDesc id needs a DAT sweep, same method as the Options panel's +`0x2100002B`. **Blocking for the panel slice, not for the wire slice.** + +**U3 — `FellowshipLockData`'s three unknown u32s.** Named +`unknown_1/2/3` by both Chorizite +(`FellowshipLockData.generated.cs:19-33`) and holtburger +(`events.rs:132-134`), and the 2013 client never reads them (§3.9). ACE +writes a 2-field `FellowshipLockData` (`Fellowship.cs:434`) — **the ACE +struct and the pcap struct may not be the same width**, which would +desynchronise a strict parser. Recommend parsing the locks table +**last** and tolerating a short/absent tail until a real capture settles +it. + +**U4 — Quit ordering under ACE.** Retail's leader-Quit emits `0x0290` +then `0x00A3` back-to-back (§2.5). ACE's `QuitFellowship` *also* calls +`AssignNewLeader(null, null)` (random) at `Fellowship.cs:340`. If the two +GameActions are processed in order the client's choice wins; the +`0x0290` handler warns and bails if the sender is no longer leader +(`Player_Fellowship.cs:117-121`). **Needs a two-client connected gate** +to confirm no warning fires and the intended fellow gets leadership. + +**U5 — does ACE ever send `updateType = 2 (UpdateStats)`?** No call site +found; only `Full` (default) and `Vitals`. Retail's client has a distinct +`UpdateFellowStats` renderer. Not blocking (retail patches the whole +record regardless), but a parity gate should not expect a `2`. + +**U6 — AD-78 dimming for `FellowshipShareXP` after the panel lands.** +`CharacterOptionsPageController.cs:198` classifies `0x0F` as Group D +(store-only). Once acdream implements Create (§2.2 reads the bit to build +`0x00A2`), that row acquires a genuine **client-side** consumer and must +be flipped to `Live` in **both** +`src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs:198` and +`tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs` +(`ExpectedStoreOnlyIds`) — the test pins the set exactly and will fail +otherwise. `0x12 FellowshipAutoAcceptRequests` gets the same treatment if +the confirmation branch reads it (§6.1 step 4); +`CharacterOptionsPageController.cs:147-154` already flags that row's +classification as "a one-line judgment call, not a re-investigation". +`0x02` and `0x11` stay dimmed — ACE remains their only consumer. + +**U7 — golden byte vectors.** holtburger ships real fixtures +(`crates/holtburger-protocol/src/test_fixtures.rs`, exercised by +`events.rs`'s `test_fellowship_full_update_fixture` with a 2-member +fellowship, `share_loot: 0x10`). **These are the cheapest source of +conformance vectors for an acdream parser** and are pack/unpack-parity +checked on the Rust side. I did not extract the byte arrays; if the +implementation lane wants them, they are one file read away. + +--- + +## 9. Executive summary + +1. **The wire is fully pinned, three ways, with retail as tiebreaker.** All + seven C→S opcodes (`0x00A2`–`0x00A6`, `0x0290`, `0x0291`) and seven S→C + opcodes (`0x00A3`, `0x00A4`, `0x01C9`, `0x01CA`, `0x02BE`–`0x02C0`) have + byte-exact field order in §3; the `Fellow` record is 10 × u32 + `str16L` + with the GUID always leading as key-or-field (§3.8), and the `Fellowship` + record is 8 client-read fields plus a 9th lock table the 2013 client + ignores (§3.9). +2. **acdream implements none of it** — enum values, five uncalled builders + (two malformed: `BuildFellowshipCreate` invents an `openness` field and + silently inverts `shareXP`; `BuildFellowshipUpdate` names `0x00A6` + "open" when openness is the missing `0x0291`), four correct option bits, + live fellowship *chat*, and a radar seam wired to `false` (AP-90). +3. **Two byte-decodes settled facts both decompilers got wrong**: max + fellowship size is `_currNum >= 9`, and the retail even-split share table + is `1.0 / .75 / .6 / .55 / .5 / .45 / .4 / .35 / .3111111 / .28`, default + `0.0` — a 2.8× total-XP cap from seven members up (§7). +4. **ACE diverges in four measurable places**: the 9-fellow share factor + (`.3` vs `.3111111`), the locked re-recruit window (600 s vs retail's + 900 s, contradicting ACE's own "15 minutes" string), two mutually + inconsistent `shareLoot` encodings (`0x10` in full updates, `<<1` in + incremental), and hard-coded `0` for `cpCached`/`lumCached` (§4). +5. **Recommended order**: `FellowshipState` + inbound parsers → fix the two + builders and add `0x0290`/`0x0291` → the `ConfirmationType 4` branch + (cheapest user-visible slice; transport already shipped) → `0x00A6` on + panel visibility (**a hard prerequisite for live vitals — ACE gates the + `0x02C0` stream on it**) → panel → radar/hotkey consumers. Store the + wire's `_even_xp_split`; never re-derive it as authority (§6.2). diff --git a/docs/research/2026-08-11-fa-panel-structure.md b/docs/research/2026-08-11-fa-panel-structure.md new file mode 100644 index 00000000..06f31378 --- /dev/null +++ b/docs/research/2026-08-11-fa-panel-structure.md @@ -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** (1–2 hits, both +in the same class). No cross-class code collision exists. + +**Layout-side (partial — 33 committed fixtures only).** I walked every `Id` field in all 33 +JSON layout fixtures under `tests/AcDream.App.Tests/UI/Layout/fixtures/` against the full +candidate range `0x10000251`–`0x1000028A` (both panels' ids plus every unreferenced id +between them) — **zero collisions**. That covers options (`0x2100002B`, `0x21000028`, +`0x21000029`, `0x2100002A`, `0x2100005C`), keyboard config (`0x21000009`), toolbar, chat, +character, inventory, paperdoll, spellbook, combat, radar, vitals, vitae, effects, appraisal, +dialogs, vendor, indicators, powerbar, link-status, mini-game, smartbox-FPS. + +**This is not a full clearance.** The campaign's `0x10000211`-in-two-layouts precedent means +the real test is the panel's own resolved subtree against the *live* trees acdream mounts, +which requires the DAT dump — §8 item **U9**. Two specific risks to check there: + +1. `0x10000490`, `0x10000492`, `0x100004AA` sit in a different, much busier numeric band than + the rest of the Allegiance panel and are the most likely to collide. +2. `0x10000492` is looked up under **two different parents** in retail + (`m_pMonarchField/0x10000490` and `m_pPatronField`) — i.e. **retail itself authors two + physical elements under one id**, and both lookups are scoped. A flat + `layout.FindElement(0x10000492)` will pick the wrong one. This is exactly the + Apply/Reset/Defaults hazard OP4 already solved with a scoped + `UiElement.FindDescendant` (`src/AcDream.App/UI/Layout/OptionsPanelController.cs:67-79`) — + **use the scoped search from day one.** + +### 6.5 Shared-state collision with the Options panel (not an id collision — a *state* one) + +Five of the six checkboxes these panels host are **duplicates of rows already live on the +Options → Character tab**: + +| Panel widget | `PlayerOption` | Also authored on the Character tab | +|---|---|---| +| Allegiance `0x10000262` | `1` `IgnoreAllegianceRequests` | `CharacterOptionsPageController.cs:195` | +| Fellowship `0x10000270` | `2` `IgnoreFellowshipRequests` | `:196` | +| Fellowship `0x10000272` | `0x0F` `FellowshipShareXP` | `:198` | +| Fellowship `0x10000273` | `0x11` `FellowshipShareLoot` | `:199` | +| Fellowship `0x10000271` | `0x12` `FellowshipAutoAcceptRequests` | `:200` | + +All five numeric values match acdream's enum exactly +(`src/AcDream.Core.Net/Messages/SocialActions.cs:365-382`). Retail's `UIOption_Checkbox` +writes through `PlayerModule::SetOption` and applies **immediately on click** +(`docs/research/2026-08-10-options-panel-structure.md` §3.7), so both surfaces are live +views of one value — and retail's `CPlayerModule::OnChanged(PlayerOption)` enforces the +`IgnoreFellowshipRequests` ↔ `FellowshipAutoAcceptRequests` mutual exclusion centrally +(already ported at `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs:794-834`). +**Port implication:** these six checkboxes must bind to +`RuntimeCharacterOptionsState`, not to panel-local state, and both surfaces must refresh +when the other writes. + +### 6.6 Widget-kind parity in acdream + +| Retail Type | acdream factory arm | Status | +|---|---|---| +| `1` Button | `DatWidgetFactory` (button arm) | ✔ | +| `5` `UIElement_ListBox` | `UiTemplateListBox` (`DatWidgetFactory.cs:105`) | ✔ built, **but see gap below** | +| `7` `UIElement_Meter` | `BuildMeter` → `UiMeter` (`DatWidgetFactory.cs:108`) | ✔ (`UiMeter.Fill` is the `SetAttribute_Float(0x69)` seam) | +| `0xC` `UIElement_Text` | `BuildText` (`DatWidgetFactory.cs:122`) | ✔ | +| `0x10000035` `UIOption_Checkbox` | `BuildCheckbox` (`DatWidgetFactory.cs:125`) | ✔ | + +**Gap found:** `UiTemplateListBox` +(`src/AcDream.App/UI/UiTemplateListBox.cs:81-229`) exposes `AddItemFromTemplateList(index)` +and `AddPrebuiltRow(row)` — but **no `Flush`, no selection model +(`SetSelectedItem`/`GetSelectedIndex`), and no per-row instance-id attribute**. Both panels +need all three (`Flush` on every `Update`; selection to drive Dismiss/Leader/Kick; the row +attribute `0x1000000D`/`0x10000001` as the identity key). This is a bounded, real +prerequisite for Campaign FA implementation — flagging it here so the planner sizes it. + +Retail detail that matters for the Open/Close button: `UpdateButtons` calls +`UIElement_Text::SetText((UIElement_Text*)m_pFellowOpenButton, …)` — i.e. retail's +`UIElement_Button` **derives from** `UIElement_Text` and a button's caption is set through +the text API (`ghidra@0x0048E6C0:115,129`). acdream's button caption seam must allow the +same runtime relabel. + +--- + +## 7. Notes and loose ends worth carrying + +### 7.1 `gmFellowshipUI::ListenToGlobalMessage` is a folded no-op — and message 3 is the frame tick + +`symbols.json` gives `gmFellowshipUI::ListenToGlobalMessage = 0x004F5860`, which is an +**empty function shared by identical-COMDAT folding** with +`gmPanelUI::ListenToGlobalMessage`, `gmPanelUI::RecvNotice_PlayerDescReceived`, +`NoticeHandler::RecvNotice_ItemAttributesChanged` and `NoticeHandler::RecvNotice_DisplayWeenieError` +(`pseudo_c:248560` / `ghidra@0x004F5860`). So Fellowship handles **no** global message +directly, despite `PostInit` registering for message 1 and `Update` toggling registration +for message 3. + +Global message **3** is byte-identified: `UIElementManager::UseTime @ 0x0045CFD0` calls +`BroadcastGlobalMessage(this, 3, 0)` once per frame, between `CheckTooltip` and +`DrawDirtyRegions` (`pseudo_c:102945`). So the register/unregister pattern is +"**tick me every frame while I have no fellowship**" — plausibly for the name-entry box's +caret, but the *effect* is UNVERIFIED because Fellowship's own handler is empty. Recorded, +not asserted. §8 item **U8**. + +### 7.2 `RegisterNoticeHandler` operands are code addresses, not ids + +`PostInit` registers 9 handlers on each panel with operands like `0x4DD20F`, `0x4DD210`, +`0x4DD246`, `0x4DD1EF`, `0x186A4` (Fellowship, `pseudo_c:156384-156392`) and `0x4DD1F0`, +`0x4DD201`, `0x4DD205`–`0x4DD208`, `0x4DD20D`, `0x4DD1EF`, `0x186A4` (Allegiance, +`pseudo_c:157398-157406`). The `0x4DD…` values are inside the code section, i.e. they are +**thunk/notice-slot addresses**, not notice ids; `0x186A4` (= 100004) looks like a genuine +numeric id. Same shape as `gmConfigUI`'s `RegisterNoticeHandler(0x4DD276, …)` recorded in +the Options doc. Not decoded here; not needed for panel structure. §8 item **U8**. + +### 7.3 Two different row-identity attribute keys + +Fellowship rows key on `0x1000000D`; Allegiance rows key on `0x10000001`. Both go through +`SetAttribute_InstanceID` / `GetAttribute_InstanceID`. Do **not** unify them in the port — +they are different authored attribute names and a shared row helper must take the key as a +parameter. (Both values also exist as element class ids — +`0x1000000D gmExternalContainerUI`, `0x10000001 gmUIElement_BurdenIndicator` — a different +namespace. Don't cross the wires.) + +--- + +## 8. Unknowns for the coordinator + +Everything below is genuinely not established from the decomp. **U2** is the only one that +blocks a mount. + +| # | Unknown | Why the decomp can't answer it | Verification recipe | +|---|---|---|---| +| **U1** | The two panels' own **LayoutDesc DIDs** | Both classes are instantiated from an authored element by `Type`; no DID is loaded in code (§2.1) | DAT sweep: enumerate LayoutDescs and find the two whose element tree contains `Type == 0x1000002D` / `Type == 0x1000002C` | +| **U2** | **Which `0x2100006E` slot element** hosts each panel, and its `P0x10000029` panel id | `SetupChildren` hard-codes the 16 slot ids but reads the key from the DAT (§2.2) | For each of the 16 ids in §2.2's table, `LayoutImporter.ImportInfos(dats, 0x2100006E, slotId)` and read (a) `P0x10000029` on the slot, (b) the subtree root's `Type`. Report the full 16-row table — it also closes the 10 currently-unidentified `RetailPanelCatalog` entries in one pass | +| **U3** | **Geometry, media, fonts, base refs** for every element in §3.1/§3.2 | Code reveals ids, not layout | Dump both slot subtrees to fixtures (`fellowship_2100006E_<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`. diff --git a/tests/AcDream.App.Tests/UI/Layout/FaPanelSlotProbeTests.cs b/tests/AcDream.App.Tests/UI/Layout/FaPanelSlotProbeTests.cs new file mode 100644 index 00000000..4e6c9b58 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/FaPanelSlotProbeTests.cs @@ -0,0 +1,95 @@ +using System.IO; +using AcDream.App.UI.Layout; +using DatReaderWriter; +using DatReaderWriter.Options; + +namespace AcDream.App.Tests.UI.Layout; + +/// <summary> +/// Campaign FA lane-A unknown U2 (docs/research/2026-08-11-fa-panel-structure.md +/// §8): which of the 16 gmPanelUI page-stack slots host the Fellowship and +/// Allegiance panels, and each slot's authored P0x10000029 panel id. Dumps the +/// full 16-row table against the live DATs — closing U2 (and the ten +/// unidentified RetailPanelCatalog entries) in one pass. Signature elements +/// identify the two FA slots definitively: 0x1000026F (fellowship name field) +/// and 0x10000263 (swear-allegiance button). Env-gated like the other probes. +/// </summary> +public sealed class FaPanelSlotProbeTests +{ + [Fact] + public void ProbePanelSlotTable() + { + if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIVE_MOUNT") != "1") + return; + + var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", + "Asheron's Call"); + using var dats = new DatCollection(datDir, DatAccessType.Read); + + // The 16 authored slot ids, in gmPanelUI::SetupChildren call order + // (lane-A doc §2.2), plus the conspicuously-absent 0x10000186. + foreach (uint slotId in new[] + { + 0x1000018Bu, 0x1000018Fu, 0x1000018Eu, 0x10000559u, + 0x1000018Cu, 0x10000182u, 0x1000018Du, 0x10000190u, + 0x10000184u, 0x10000185u, 0x10000181u, 0x10000189u, + 0x10000183u, 0x1000018Au, 0x10000187u, 0x10000188u, + 0x10000186u, + }) + { + ElementInfo? slot = LayoutImporter.ImportInfos(dats, 0x2100006Eu, slotId); + if (slot is null) + { + Console.WriteLine($"[faslot] 0x{slotId:X8} -> IMPORT NULL"); + continue; + } + + string panelId = slot.TryGetEffectiveProperty(0x10000029u, out var p) + ? $"{p.UnsignedValue} (kind={p.Kind})" + : "ABSENT"; + + bool hasFellowshipName = FindInfo(slot, 0x1000026Fu); + // Allegiance signature set (lane A/C ids): swear 0x10000263, + // break 0x10000264, kick 0x10000265, row texts 0x10000268/69, + // and the doubled 0x10000492 monarch/patron child. + bool hasAllegiance = FindInfo(slot, 0x10000263u) || FindInfo(slot, 0x10000264u) + || FindInfo(slot, 0x10000265u) || FindInfo(slot, 0x10000268u) + || FindInfo(slot, 0x10000269u) || FindInfo(slot, 0x10000492u); + string family = hasFellowshipName ? " <= FELLOWSHIP" + : hasAllegiance ? " <= ALLEGIANCE" + : string.Empty; + + Console.WriteLine( + $"[faslot] 0x{slotId:X8} panelId={panelId} type={slot.Type} " + + $"({slot.X},{slot.Y} {slot.Width}x{slot.Height}) " + + $"children={slot.Children.Count}{family}"); + + // The unidentified slots: dump two levels of child ids so the + // allegiance panel can be identified by SHAPE if its recovered + // element ids are absent from the base-merge. + bool unidentified = slotId is 0x1000018Fu; + if (unidentified) + { + foreach (ElementInfo c in slot.Children) + { + Console.WriteLine( + $"[faslot] child 0x{c.Id:X8} type={c.Type} ({c.X},{c.Y} {c.Width}x{c.Height}) kids={c.Children.Count}"); + foreach (ElementInfo g in c.Children) + Console.WriteLine( + $"[faslot] g 0x{g.Id:X8} type={g.Type} ({g.X},{g.Y} {g.Width}x{g.Height})"); + } + } + } + } + + private static bool FindInfo(ElementInfo info, uint id) + { + if (info.Id == id) return true; + foreach (ElementInfo c in info.Children) + if (FindInfo(c, id)) return true; + return false; + } +}