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

64 KiB
Raw Blame History

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 @0x0048F730CM_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 @0x0048E530Event_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 @0x0048F2C0CM_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-102FellowshipIgnoringRequests (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:121AddConfirmedMember(…, 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 0xCEvent_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 @0x0048F1E0Event_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 9Event_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 @0x0048E460Event_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-6329-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-532FellowshipQuit(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-74Fellowship.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:333OnFellowLevelUp 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-448emote-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 LIVEChatInputParser.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:36647Fellow (the per-member record; the member GUID is not a field, it is the hash-table key):

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:36731Fellowship (8 packed members):

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*:

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:

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 0x0F0x12 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-12ReadString16L() 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 @0x0048E460the 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-12identical: 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) << 10 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:93AddSeconds(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 callergrep 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 falseAP-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:

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 FellowshipUpdateRequestpanel 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 (0x00A20x00A6, 0x0290, 0x0291) and seven S→C opcodes (0x00A3, 0x00A4, 0x01C9, 0x01CA, 0x02BE0x02C0) 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).