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>
64 KiB
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 — onlyreferences/WorldBuilderexists here. Everyreferences/…citation below resolves against the main repo atC:\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:
- The fellowship panel is a pure projection of one message. Every
visible field comes from
0x02BE FullUpdate, incrementally patched by0x02C0 UpdateFellowand removed by0x00A3/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. - 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):
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 @0x005B9AD0and is different from the struct above. Use §3.8, never this listing.
acclient.h:36731 — Fellowship (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:
- read
m_pFellowshipNameEntryBoxtext (@0x0048F752); 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;eax_8 = PlayerModule::FellowshipShareXP(...)(@0x0048F7F7);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:11is 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 : 1SetState((((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_UpdateFellowis missing the leading guid (protocol.xml:7807-7810declares onlyFellow+FellowUpdateType, andTypes/Fellow.generated.cs:82starts atXPCached). 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:
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)
FellowshipStateinAcDream.Core/Social/— mirrorFriendsState.cs. Fields exactly as §3.9, members keyed by guid with the §3.8 record.shareLootas rawuint, notbool(§4.1).- Parsers +
GameEventWiringregistrations for0x02BE,0x02C0,0x02BF,0x00A3,0x00A4; parse-and-ignore0x01C9/0x01CA. The dispatcher seam already exists —GameEventWiring.WireAll(src/AcDream.Core.Net/GameEventWiring.cs). - Fix the two builders (§5.2) and add
0x0290+0x0291. - Confirmation type-4 branch — smallest user-visible slice; the
0x0274/0x0275/0x0276transport is already shipped. Must consultFellowshipAutoAcceptRequestsso no popup appears when ACE auto-accepted (§1, feature 6). 0x00A6on panel show/hide — a prerequisite for live vitals (§4.5).- The panel (
gmFellowshipUI), with the §2.8 button rules and the §2.9 percentage text. - Radar markers (AP-90) and the N/M/F4 input consumers — both are pure consumers of step 1 with no new mechanism.
- The 8 missing WeenieError strings (§5.1) —
0x0417and0x04DBare on ACE's real send paths, so they are user-visible gaps today.
6.2 What to store, not compute
_even_xp_splitand_share_xp: take the wire value. The retail client'sRecalculateEvenXPSplitting(§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 a0x02BE.- 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
FellowshipShareXPa 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 @0x005B91B0genuinely returns0xFFFFFFFFwhen the leader is not in the member table — that is a real sentinel, not an artifact.RecalculateEvenXPSplittingthen comparesmaxLevel > 0xFFFFFFFF + 5in unsigned arithmetic; treat a leaderless table as "leave_even_xp_splitat 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 againstacclient.h:36663-36670before trusting any new offset.HandleLockedRemoveFellow @0x005B9270shows BN calling three differently-typedPackableHashTabletemplate 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
- 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; theFellowrecord is 10 × u32 +str16Lwith the GUID always leading as key-or-field (§3.8), and theFellowshiprecord is 8 client-read fields plus a 9th lock table the 2013 client ignores (§3.9). - acdream implements none of it — enum values, five uncalled builders
(two malformed:
BuildFellowshipCreateinvents anopennessfield and silently invertsshareXP;BuildFellowshipUpdatenames0x00A6"open" when openness is the missing0x0291), four correct option bits, live fellowship chat, and a radar seam wired tofalse(AP-90). - Two byte-decodes settled facts both decompilers got wrong: max
fellowship size is
_currNum >= 9, and the retail even-split share table is1.0 / .75 / .6 / .55 / .5 / .45 / .4 / .35 / .3111111 / .28, default0.0— a 2.8× total-XP cap from seven members up (§7). - ACE diverges in four measurable places: the 9-fellow share factor
(
.3vs.3111111), the locked re-recruit window (600 s vs retail's 900 s, contradicting ACE's own "15 minutes" string), two mutually inconsistentshareLootencodings (0x10in full updates,<<1in incremental), and hard-coded0forcpCached/lumCached(§4). - Recommended order:
FellowshipState+ inbound parsers → fix the two builders and add0x0290/0x0291→ theConfirmationType 4branch (cheapest user-visible slice; transport already shipped) →0x00A6on panel visibility (a hard prerequisite for live vitals — ACE gates the0x02C0stream on it) → panel → radar/hotkey consumers. Store the wire's_even_xp_split; never re-derive it as authority (§6.2).