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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 23:42:19 +02:00

1077 lines
64 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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).