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>
1077 lines
64 KiB
Markdown
1077 lines
64 KiB
Markdown
# Fellowship mechanics + wire map — Campaign FA research lane B
|
||
|
||
**Campaign:** FA. **Lane:** B — the complete fellowship feature map, each
|
||
feature pinned end-to-end (retail client behavior → wire format → ACE
|
||
server handling → acdream current state).
|
||
**Date:** 2026-08-11. **Mode:** research only — nothing built, nothing
|
||
launched, no source changed. This doc is the only file written.
|
||
|
||
**Quality bar / format:** `docs/research/2026-08-10-character-options-map.md`.
|
||
|
||
**Sources.**
|
||
|
||
| Source | Path | Used for |
|
||
|---|---|---|
|
||
| BN pseudo-C, Sept 2013 EoR build | `docs/research/named-retail/acclient_2013_pseudo_c.txt` | retail client mechanism + wire builders |
|
||
| Verbatim retail headers | `docs/research/named-retail/acclient.h` | `Fellow` / `Fellowship` / `gmFellowshipUI` / `PlayerOption` struct truth |
|
||
| PDB symbol table | `docs/research/named-retail/symbols.json` | `class::method` → address |
|
||
| PDB-paired binary | `C:\Users\erikn\Downloads\acclient.exe` (ImageBase `0x00400000`, GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`, linker 2013-09-06T00:17:56Z) | **byte-decode of two BN-folded functions** (§7) |
|
||
| ACE (server truth) | `references/ACE/Source/` (main repo, not the worktree) | what the server actually implements/validates |
|
||
| Chorizite.ACProtocol | `references/Chorizite.ACProtocol/` | field comments, pcap-derived shapes |
|
||
| holtburger (client truth) | `references/holtburger/crates/holtburger-protocol/src/messages/fellowship/` | third independent client-side parser |
|
||
| acdream | `src/` in this worktree | current state |
|
||
|
||
> **Path note.** `references/` is **not** populated in this worktree —
|
||
> only `references/WorldBuilder` exists here. Every `references/…`
|
||
> citation below resolves against the **main repo** at
|
||
> `C:\Users\erikn\source\repos\acdream\references\…`.
|
||
|
||
---
|
||
|
||
## 0. Verdict summary
|
||
|
||
| Question | Answer |
|
||
|---|---|
|
||
| Is the fellowship wire format fully pinned? | **Yes, three-way, with retail as tiebreaker.** All 7 C→S opcodes and all 7 S→C opcodes have byte-exact field order from the retail builders/dispatchers, corroborated by ACE and holtburger. Two reference disagreements resolved (§4.1, §4.2). |
|
||
| Does acdream implement any of it? | **No. Zero fellowship state, zero inbound parsing.** What exists: the opcode enums, five *uncalled* outbound builders (two of them malformed), four character-option bits, the Fellowship **chat** channel (fully live), and a radar relationship seam hard-wired to `false`. |
|
||
| Are acdream's existing builders safe to call today? | **No.** `BuildFellowshipCreate` invents a nonexistent `openness` field and silently sends the WRONG `shareXP` value (§5.2). `BuildFellowshipUpdate`'s `open` parameter names the wrong feature — `0x00A6` is *panel visibility*, and openness is a separate opcode acdream does not have (§5.2). |
|
||
| Max fellowship size? | **9, byte-verified.** `Fellowship::IsFull @0x005B9070` decodes to `_currNum >= 9` (§7.1). Matches ACE `Fellowship.cs:26`. |
|
||
| Is ACE's XP share table right? | **No — one row is wrong.** Retail's 9-fellow even-split factor is **0.3111111** (byte-decoded, §7.2); ACE uses `.3` (`Fellowship.cs:627`). Retail also has a 10-fellow row (0.28) and a 0.0 default that ACE lacks. |
|
||
| Anything else ACE gets wrong? | The locked-fellowship re-recruit window is **900 s in retail**, 600 s in ACE — and ACE's own broadcast string says "15 minutes" (§4.4). Plus two internally-inconsistent `shareLoot` encodings (§4.1). |
|
||
| Is `FellowUpdateDone`/`FellowStatsDone` real? | **No — dead in the 2013 client.** Both handlers COMDAT-fold onto `return 0` (§2.7). ACE's own comment guessed this correctly. |
|
||
| Where does accept/decline live? | **Not a fellowship opcode.** It rides the shared Confirmation triple `0x0274` / `0x0275` / `0x0276` with `ConfirmationType = 4` (§3.10). acdream already parses `0x0274`/`0x0276` — the type-4 branch is unimplemented. |
|
||
|
||
Two facts worth stating up front because they change scope estimates:
|
||
|
||
1. **The fellowship panel is a pure projection of one message.** Every
|
||
visible field comes from `0x02BE FullUpdate`, incrementally patched by
|
||
`0x02C0 UpdateFellow` and removed by `0x00A3`/`0x00A4`/`0x02BF`. There
|
||
is no client-side authority to model — except the two display
|
||
computations in §2.9 and the button-enable rules in §2.8.
|
||
2. **Fellowship chat already works in acdream** (channel bit `0x800`,
|
||
`ChatInputParser.cs:87-93`). Only the *roster* is missing. That means
|
||
the observable gap is the panel, the radar markers (AP-90), and the
|
||
member vitals — not the social channel.
|
||
|
||
---
|
||
|
||
## 1. Master table
|
||
|
||
Legend for **acdream status**: `absent` = nothing exists; `enum-only` =
|
||
the opcode value is declared but nothing reads/writes it; `builder-only`
|
||
= an outbound builder exists but has **no production caller**;
|
||
`live` = shipped and reachable by a user.
|
||
|
||
| # | Feature | Retail anchor | C→S wire | S→C wire | ACE status | acdream status | Recommendation |
|
||
|---|---|---|---|---|---|---|---|
|
||
| 1 | **Create** (name + share-XP) | `gmFellowshipUI::CreateFellowship @0x0048F730` → `CM_Fellowship::Event_Create @0x006A67A0` | `0x00A2` `[str16L name][u32 shareXP]` | `0x02BE` FullUpdate (+ dead `0x01C9`) | Implemented, `GameActionFellowshipCreate.cs:11-14`; Olthoi refusal `Player_Fellowship.cs:22-26`; **no name validation** (§4.6) | **builder-only + MALFORMED** (`SocialActions.cs:123-138`) | **Implement.** Fix the builder first (§5.2). shareXP comes from option `0x0F`, not a dialog checkbox. |
|
||
| 2 | **Recruit / invite** | `gmFellowshipUI::RecruitFellow @0x0048E530` → `Event_Recruit @0x006A62B0` | `0x00A5` `[u32 targetGuid]` | (none direct) → `0x0274` ConfirmationRequest to the *target*; `0x02C0` + `0x02BE` to everyone on accept | Implemented, `GameActionFellowshipRecruit.cs:10-13`; gates in `Player_Fellowship.cs:86-110` + `Fellowship.cs:76-132` | **builder-only** (`SocialActions.cs:156-157`) | **Implement.** Target = `ACCWeenieObject::selectedID` (world selection), see §2.3. |
|
||
| 3 | **Recruit-target selection** | `gmFellowshipUI::ListenToElementMessage @0x004901C0` case `0xA` | — | — | n/a (client-side) | absent | **Implement** — reuse the existing world-picker selection; no new mechanism. |
|
||
| 4 | **Accept / decline** | `gmFellowshipUI::MakeFellowRequestDialog @0x00490620`, `RecvNotice_CloseDialog @0x0048F2C0` → `CM_Character::Event_ConfirmationResponse @0x006A1210` | `0x0275` `[u32 type=4][u32 context][u32 response]` | `0x0274` `[u32 type=4][u32 context][str16L inviterName]`; abort `0x0276` `[u32 type][u32 context]` | Implemented, `GameActionConfirmationResponse.cs:11-15`, `ConfirmationManager.cs:143`, `Confirmation.cs:140-165`; 30 s timeout `ConfirmationManager.cs:19` | `0x0274`/`0x0276` **parsed** (`GameEventWiring.cs:202,212`), type-4 branch **absent** (`GameplayConfirmationController.cs:37` names the gap) | **Implement** — smallest slice; the transport already exists. |
|
||
| 5 | **IgnoreFellowshipRequests** option | `PlayerModule::IgnoreFellowshipRequests @0x005D2B30` | `0x0005` `[u32 id=0x02][u32 value]` (auto-save) | — | Read at `Player_Fellowship.cs:98-102` → `FellowshipIgnoringRequests (0x0417)` | **live storage, store-only UI** — id `0x02`, mask `0x00000008` (`CharacterOptionTable.cs:113`), dimmed by AD-78 | **Store only.** ACE is the consumer; acdream has nothing to do beyond sending the bit (already correct). |
|
||
| 6 | **FellowshipAutoAcceptRequests** option | `PlayerModule::FellowshipAutoAcceptRequests @0x005D3020`; mutual-exclusion in `CPlayerModule::OnChanged @0x0059A8E0` cases `2`/`0x12` | `0x0005` `[u32 id=0x12][u32 value]` (auto-save) | — | Read at `Fellowship.cs:121` → `AddConfirmedMember(…, true)` (skips the popup) | **live storage + retail-exact mutual exclusion** (`RuntimeCharacterState.cs:821-834`), id `0x12`, mask `0x20000000`; store-only UI | **Store only** — but note it *suppresses feature 4's dialog*, so feature 4's implementation must read this bit or it will show a popup ACE never sent. |
|
||
| 7 | **Dismiss** | `gmFellowshipUI::DismissFellow @0x0048E470`, button case `0xB` | `0x00A4` `[u32 guid]` | `0x00A4` `[u32 guid]` to all + `0x02BE` | Implemented, `GameActionFellowshipDismiss.cs:8-11`; leader-only + no-self gates `Player_Fellowship.cs:62-84` | **builder-only** (`SocialActions.cs:152-153`) | **Implement.** |
|
||
| 8 | **Quit** | button case `8` (auto-transfers leadership first!) → `Event_Quit(0) @0x006A61E0` | `0x00A3` `[u32 disband]` | `0x00A3` `[u32 quitterGuid]` to self *and* all | Implemented, `GameActionFellowshipQuit.cs:9-11`, `Fellowship.cs:286-375` | **builder-only** (`SocialActions.cs:141-149`) | **Implement**, including the retail pre-quit leader handoff (§2.8). |
|
||
| 9 | **Disband** | button case `0xC` → `Event_Quit(1)` | `0x00A3` `[u32 disband=1]` | `0x02BF` (empty body) to all | Implemented, `Fellowship.cs:290-311` | **absent** (no `disband:true` caller) | **Implement** — same opcode as Quit, flag flipped. |
|
||
| 10 | **Leader transfer** | `gmFellowshipUI::AssignLeadershipToFellow @0x0048F1E0` → `Event_AssignNewLeader @0x006A5F70` | **`0x0290`** `[u32 newLeaderGuid]` | `WeenieErrorWithString` `0x050E` to old leader + `0x050D` to all + `0x02BE` | Implemented, `GameActionFellowshipAssignNewLeader.cs:8-10`, `Player_Fellowship.cs:112-135`, `Fellowship.cs:378-408` | **absent — opcode not even declared** | **Implement.** Add `0x0290` to `SocialActions`. |
|
||
| 11 | **Open vs closed** | button case `9` → `Event_ChangeFellowOpeness @0x006A6040` (client pre-toggles its own `_open_fellow`) | **`0x0291`** `[u32 isOpen]` | `WeenieErrorWithString` `0x050B`/`0x050C` + `0x02BE` | Implemented, `GameActionFellowshipChangeOpenness.cs:9-11`; leader-only + locked refusal `Player_Fellowship.cs:33-48` | **absent — opcode not declared**; `BuildFellowshipUpdate(open:)` is a **misnamed `0x00A6`** | **Implement `0x0291`; rename/repurpose the `0x00A6` builder.** |
|
||
| 12 | **Panel visibility → update request** | `gmFellowshipUI::OnVisibilityChanged @0x0048E460` → `Event_UpdateRequest @0x006A6380` | `0x00A6` `[u32 panelOpen]` | `0x02BE` if in a fellowship | Implemented, `GameActionFellowshipUpdateRequest.cs:11-13`; sets `FellowshipPanelOpen`, which **gates the vitals stream** (`Fellowship.cs:723`) | **builder exists but is documented as "open"** | **Implement with correct semantics.** Without it ACE sends **no** vital updates (§4.5). |
|
||
| 13 | **Member update stream (vitals)** | `ClientFellowshipSystem::Handle_Fellowship__UpdateFellow @0x005690C0`, `gmFellowshipUI::UpdateFellowVitals @0x0048ED60` | — | `0x02C0` `[u32 guid][Fellow][u32 updateType]`, `updateType = 3 (Vitals)` | Implemented, `Fellowship.OnVitalUpdate` `Fellowship.cs:715-726`, driven from `Player_Tick.cs:84-89` via the `FellowVitalUpdate` latch | absent | **Implement** (parse + roster patch). |
|
||
| 14 | **Member update stream (add/full/stats)** | same | — | `0x02C0` with `updateType = 1 (Full)` on join (`Fellowship.cs:166`) | Implemented. **ACE never sends `updateType = 2 (Stats)`** | absent | **Implement**; treat 1/2/3 identically (retail patches the whole `Fellow` regardless — §2.6). |
|
||
| 15 | **XP sharing — even/proportional decision** | `Fellowship::RecalculateEvenXPSplitting @0x005B92E0` | — | `_even_xp_split` field of `0x02BE` | `Fellowship.CalculateXPSharing() Fellowship.cs:453-494` — same rule + a 3rd "≤10 levels ⇒ proportional" tier | absent | **Store the wire flag; do NOT re-derive.** The client's local recompute is display-only (§6.2). |
|
||
| 16 | **XP sharing — share table** | `FellowshipSystem::GetEvenSplitXPPctg @0x005B9BA0` (**byte-decoded**, §7.2) | — | — | `GetMemberSharePercent() Fellowship.cs:604-632` — **9-fellow row diverges** (§4.3) | absent | **Client-side: needed only for the panel's per-fellow % text** (§2.9). File the ACE divergence. |
|
||
| 17 | **XP sharing — proportional weight** | `FellowshipSystem::GetExperienceProportion @0x005B9B80` = `ExperienceToRaiseLevel(L, L+1)` | — | — | `GetXPToNextLevel(Level)` `Fellowship.cs:545,550` — same idea | absent | Same as 16 — panel display only. |
|
||
| 18 | **XP sharing — proximity** | *(server-side; no client function)* | — | — | `GetDistanceScalar Fellowship.cs:640-668`: indoor/outdoor mismatch ⇒ 0; different indoor landblock ⇒ 0; ≤600 ⇒ 1.0; ≥1200 ⇒ 0; linear between | absent | **Nothing to implement client-side.** Document only. |
|
||
| 19 | **Luminance sharing** | *(no client function; `_lum_cache` field only)* | — | `lumCached` u32 in `Fellow` | `SplitLuminance Fellowship.cs:567-603`: quest lum not shared; otherwise flat split over ALL members, then filtered to `WithinRange` (radar 75 m outdoor / 25 m indoor) | absent | **Display only** (`lumCached`). |
|
||
| 20 | **Max size** | `Fellowship::IsFull @0x005B9070` ⇒ `_currNum >= 9` (§7.1) | — | — | `MaxFellows = 9` `Fellowship.cs:26`; enforced `Fellowship.cs:102-106` and again `:151-155` | absent | **Implement** as the Recruit-button enable rule only. Server is authoritative. |
|
||
| 21 | **Logoff** | `ClientFellowshipSystem::OnEndCharacterSession @0x005690A0` (frees the local copy) | — | `0x00A3` Quit to remaining members | `LogOut_Inner Player.cs:531-532` → `FellowshipQuit(false)` — **a logoff is a permanent quit, not a suspend** | absent | **Implement teardown** on session end (mirror the existing reset ledgers). |
|
||
| 22 | **Death** | — | — | `GameMessageSystemChat` "Your fellow X has died!" | `Player_Death.cs:73-74` → `Fellowship.OnDeath` `Fellowship.cs:728-737`. **Death does NOT remove you from the fellowship.** | chat line renders today (generic system chat) | Nothing extra. |
|
||
| 23 | **Portal / teleport** | — | — | none | **No fellowship hook anywhere in ACE's teleport path.** Membership survives; only `GetDistanceScalar` observes the new position. | absent | Nothing. |
|
||
| 24 | **Level-up** | — | — | `0x02BE` (via `CalculateXPSharing`) + chat "X is now level N!" | `Player_Xp.cs:333` → `OnFellowLevelUp Fellowship.cs:700-713` | absent | Nothing extra; falls out of `0x02BE`. |
|
||
| 25 | **Lock / departed members** | `Fellowship::HandleLockedRemoveFellow @0x005B9270`, `AddFellow @0x005B9480` (**900 s**) | — | `_locked` + `_fellows_departed` in `0x02BE` | `UpdateLock Fellowship.cs:418-448` — **emote-driven only**, no GameAction. **600 s** window (§4.4) | absent | **Parse and store** the two fields; no client action exists (retail has no lock button). |
|
||
| 26 | **Fellowship chat** | `Channel` bit `0x800`; `RetailLogTextType 0x13` | `0x0147` ChannelBroadcast family | `0x0147` | `TellFellow Fellowship.cs:266-272`, `BroadcastToFellow :258-263` | **LIVE** — `ChatInputParser.cs:87-93`, `ChannelResolver.cs:39`, `LegacyChannelChatType.cs:78` | Done. |
|
||
| 27 | **Radar relationship markers** | `CM_Character::SendNotice_ChangeRadarLook` fired from every fellowship handler | — | — | n/a | seam exists, hard-wired `false` (`RadarBlipColors.cs:60-61`) — **AP-90** | **Implement** as a consumer of the new roster; zero new mechanism. |
|
||
| 28 | **Next/Previous fellow hotkeys** | `ClientFellowshipSystem::SelectNextFellow @0x005694F0` / `SelectPreviousFellow @0x005695C0` | — | — | n/a | actions declared (`InputAction.cs:86,88`, bound N/M `KeyBindings.cs:201-202`) — **no consumer** | **Implement** once the roster exists. |
|
||
| 29 | **Fellowship panel toggle (F4)** | `gmFellowshipUI` | `0x00A6` on show/hide | — | n/a | action declared (`InputAction.cs:106`, F4 `KeyBindings.cs:210`) — **no consumer, no panel** | **Implement** with the panel. |
|
||
| 30 | **Fellowship kill-task / quest sharing** | — | — | — | `Creature_Death.cs:291,414` uses `WithinRange`; `QuestManager` per-fellowship (`Fellowship.cs:46`) | absent | Server-side only. |
|
||
| 31 | **Loot permission** | `Fellow._share_loot` | — | `shareLoot` u32 in `Fellow` | `ShareLoot` set once at create from the leader's option `0x11` (`Fellowship.cs:58`); enforced `Corpse.cs:192-198` | option `0x11` stored (`CharacterOptionTable.cs:128`), dimmed | **Store + display**; ACE enforces. |
|
||
|
||
---
|
||
|
||
## 2. Retail client mechanism
|
||
|
||
### 2.1 Object model
|
||
|
||
`acclient.h:36647` — **`Fellow`** (the per-member record; the member GUID
|
||
is *not* a field, it is the hash-table **key**):
|
||
|
||
```c
|
||
struct __cppobj Fellow : PackObj {
|
||
AC1Legacy::PStringBase<char> _name;
|
||
unsigned int _level;
|
||
unsigned int _cp_cache;
|
||
unsigned int _lum_cache;
|
||
int _share_loot;
|
||
unsigned int _max_health;
|
||
unsigned int _max_stamina;
|
||
unsigned int _max_mana;
|
||
unsigned int _current_health;
|
||
unsigned int _current_stamina;
|
||
unsigned int _current_mana;
|
||
};
|
||
```
|
||
|
||
> **Declaration order ≠ wire order.** The wire order is fixed by
|
||
> `Fellow::Pack @0x005B9A10` / `Fellow::UnPack @0x005B9AD0` and is
|
||
> **different** from the struct above. Use §3.8, never this listing.
|
||
|
||
`acclient.h:36731` — **`Fellowship`** (8 packed members):
|
||
|
||
```c
|
||
struct __cppobj Fellowship : PackObj {
|
||
PackableHashTable<unsigned long,Fellow> _fellowship_table;
|
||
AC1Legacy::PStringBase<char> _name;
|
||
unsigned int _leader;
|
||
int _share_xp;
|
||
int _even_xp_split;
|
||
int _open_fellow;
|
||
int _locked;
|
||
PackableHashTable<unsigned long,long> _fellows_departed;
|
||
};
|
||
```
|
||
|
||
`CFellowship : Fellowship` (`acclient.h:37758`) adds nothing;
|
||
`sizeof == 0x44` (68) from the allocation at
|
||
`Handle_Fellowship__FullUpdate @0x005693DC`.
|
||
|
||
`ClientFellowshipSystem` (`acclient.h:40752`) holds exactly one
|
||
`CFellowship*`:
|
||
|
||
```c
|
||
struct __cppobj ClientFellowshipSystem : ClientSystem {
|
||
Turbine_RefCount m_cTurbineRefCount;
|
||
CFellowship *m_pFellowship; // null == not in a fellowship
|
||
};
|
||
```
|
||
|
||
`gmFellowshipUI` (`acclient.h:56065`) — the panel, and the complete
|
||
control inventory:
|
||
|
||
```c
|
||
struct __cppobj gmFellowshipUI : UIElement_Field, gmNoticeHandler {
|
||
CFellowship *m_pFellowship;
|
||
unsigned int m_iidSelectedFellow; // list-box selection
|
||
unsigned int m_uiAcceptFellowRequestServerContextID;
|
||
unsigned int m_fellowRequestContext;
|
||
UIElement *m_pNotInAFellowshipFrame;
|
||
UIElement *m_pInAFellowshipFrame;
|
||
UIElement_Text *m_pFellowshipNameEntryBox;
|
||
UIElement_Button *m_pCreateFellowshipButton;
|
||
UIElement_Text *m_pFellowshipName;
|
||
UIElement_ListBox *m_pFellowsListBox;
|
||
UIElement_Button *m_pFellowLeaderButton;
|
||
UIElement_Button *m_pFellowQuitButton;
|
||
UIElement_Button *m_pFellowOpenButton;
|
||
UIElement_Button *m_pFellowRecruitButton;
|
||
UIElement_Button *m_pFellowDismissButton;
|
||
UIElement_Button *m_pFellowDisbandButton;
|
||
};
|
||
```
|
||
|
||
### 2.2 Create
|
||
|
||
`gmFellowshipUI::CreateFellowship @0x0048F730`:
|
||
|
||
1. read `m_pFellowshipNameEntryBox` text (`@0x0048F752`);
|
||
2. `ACCharGenData::FormatName(&var_24)` (`@0x0048F7AE`) — the **same**
|
||
name formatter chargen uses — then write the formatted text back into
|
||
the entry box (`@0x0048F7CD`), so the user sees the canonicalised name;
|
||
3. `eax_8 = PlayerModule::FellowshipShareXP(...)` (`@0x0048F7F7`);
|
||
4. `CM_Fellowship::Event_Create(name, eax_8)` (`@0x0048F82F`).
|
||
|
||
**The share-XP flag is the character-option bit, not a dialog control.**
|
||
There is no share-XP checkbox in the create flow — the value comes from
|
||
`PlayerOption 0x0F` (`CharacterOptions1` bit 18, mask `0x00040000`,
|
||
`PlayerModule::FellowshipShareXP @0x005D2F30`). ACE independently states
|
||
the same thing at `Fellowship.cs:31`.
|
||
|
||
The Create button's enable state is driven by the entry box: message case
|
||
`0x43` in `ListenToElementMessage @0x004902A3` sets state `0xD`
|
||
(disabled) when the text is empty and `1` (enabled) otherwise.
|
||
|
||
### 2.3 Recruit
|
||
|
||
`gmFellowshipUI::RecruitFellow(guid) @0x0048E530`:
|
||
|
||
```
|
||
if (m_pFellowship == 0) return; // not in a fellowship
|
||
wo = ClientObjMaintSystem::GetWeenieObject(guid);
|
||
if (wo == 0 || !wo->IsPlayer()) return; // silent
|
||
if (Fellowship::IsFellow(m_pFellowship, guid)) { DisplayStringInfo(0x1A, …); return; }
|
||
CM_Fellowship::Event_Recruit(guid); // 0x00A5
|
||
```
|
||
|
||
The **target comes from the world selection**, not the panel list:
|
||
button case `0xA` (`@0x004903BE`) passes
|
||
`ACCWeenieObject::selectedID`. (The panel's own list-box selection is
|
||
used for Dismiss and Leader instead.)
|
||
|
||
The `_share_loot`-adjacent chat lines ("has given you permission to loot…")
|
||
are server-authored (`Fellowship.cs:170-177`), not client strings.
|
||
|
||
### 2.4 Accept / decline
|
||
|
||
Retail has **no fellowship-specific accept opcode**. The flow is:
|
||
|
||
```
|
||
S→C 0x0274 CharacterConfirmationRequest [u32 type=4][u32 context][str16L inviterName]
|
||
→ CM_Character::DispatchUI_ConfirmationRequest @0x006A2D90
|
||
→ ClientUISystem::Handle_Character__ConfirmationRequest @0x005640A0 (switch on type)
|
||
case 4 → CM_Fellowship::SendNotice_FellowshipRequest(name, context) @0x006A6650
|
||
→ gmFellowshipUI::RecvNotice_FellowshipRequest @0x00490880
|
||
→ gmFellowshipUI::MakeFellowRequestDialog @0x00490620
|
||
DialogFactory::MakeDialogInCurrentUI(...)
|
||
this->m_uiAcceptFellowRequestServerContextID = context; @0x004907E5
|
||
|
||
C→S gmFellowshipUI::RecvNotice_CloseDialog @0x0048F2C0
|
||
→ CM_Character::Event_ConfirmationResponse(4, context, response) @0x0048F3A6
|
||
→ opcode 0x0275, 0x18-byte buffer @0x006A1210
|
||
```
|
||
|
||
`MakeFellowRequestDialog` **early-returns if `m_fellowRequestContext != 0`**
|
||
(`@0x00490635`) — retail shows at most **one** pending fellowship dialog.
|
||
ACE independently enforces one-per-`ConfirmationType`
|
||
(`ConfirmationManager.cs:35`).
|
||
|
||
Server abort: `0x0276 CharacterConfirmationDone [u32 type][u32 context]`
|
||
(`CM_Character::DispatchUI_ConfirmationDone @0x006A0E20`, checks
|
||
`*(arg2) == 0x276`, reads +4 and +8).
|
||
|
||
### 2.5 Dismiss / Quit / Disband / Leader
|
||
|
||
All four are one-line wrappers with identical guards
|
||
(`Fellowship::IsFellow` + not-self, else a `DisplayStringInfo(0x1A, …)`
|
||
local message):
|
||
|
||
| Function | Address | Guard | Sends |
|
||
|---|---|---|---|
|
||
| `DismissFellow(guid)` | `0x0048E470` | must be a fellow **and** not self | `0x00A4` |
|
||
| `AssignLeadershipToFellow(guid)` | `0x0048F1E0` | must be a fellow **and** not self; else literal `L"That person is not in the fellow…"` (`@0x0048F20F`) | `0x0290` |
|
||
| Quit button (case `8`) | `0x0049034B` | — | see below |
|
||
| Disband button (case `0xC`) | `0x004903EF` | — | `0x00A3` with `disband = 1` |
|
||
|
||
**The Quit button auto-transfers leadership first** (`@0x0049034B`):
|
||
|
||
```
|
||
if (m_pFellowship != 0 && m_pFellowship->_leader == GetPlayerID()) {
|
||
id = Fellowship::GetNonLeaderFellowID(m_pFellowship); // @0x005B9200
|
||
if (id != 0) AssignLeadershipToFellow(id); // sends 0x0290
|
||
}
|
||
CM_Fellowship::Event_Quit(0); // then 0x00A3 disband=0
|
||
```
|
||
|
||
So a leader pressing **Quit** emits **two** GameActions in order:
|
||
`0x0290` then `0x00A3`. ACE's own leader-quit path also reassigns
|
||
(`Fellowship.cs:340` `AssignNewLeader(null, null)` — random pick), so the
|
||
client's explicit handoff wins the race and the random pick becomes a
|
||
no-op only if the client's `0x0290` lands first. **Ordering matters** —
|
||
see §8/U4.
|
||
|
||
### 2.6 Inbound handlers
|
||
|
||
| S→C | Dispatcher | Handler | Effect |
|
||
|---|---|---|---|
|
||
| `0x02BE` FullUpdate | `CM_Fellowship::DispatchUI_FullUpdate @0x006A68A0` | `Handle_Fellowship__FullUpdate @0x005693C0` | allocates `CFellowship` if absent; `Fellowship::operator=`; `SendNotice_FellowshipUpdate`; on **first** update walks the table firing `SendNotice_ChangeRadarLook` per member |
|
||
| `0x02C0` UpdateFellow | `DispatchUI_UpdateFellow @0x006A6700` | `Handle_Fellowship__UpdateFellow @0x005690C0` | `wasFellow = IsFellow(guid)`; `Fellowship::UpdateFellow` (**upsert** — falls through to `AddFellow` when absent, `@0x005B976F`); if new → radar look + `SendNotice_FellowAdded`; always `SendNotice_FellowUpdated(guid, fellow, updateType)`; `SetTalkFocusEnabled(3, 1)` |
|
||
| `0x02BF` Disband | `DispatchUI_Disband @0x006A5E80` | `Handle_Fellowship__Disband @0x00569E70` | `DeleteFellowship`; `SendNotice_FellowshipDisbanded`; `SetTalkFocusEnabled(3, 0)` |
|
||
| `0x00A3` Quit | `DispatchUI_Quit @0x006A5F40` | `Handle_Fellowship__Quit @0x00569E90` | if guid != me → `RemoveFellow` + radar look; **else `DeleteFellowship`**; `SendNotice_FellowQuit`; if it was me, `SetTalkFocusEnabled(3, 0)` |
|
||
| `0x00A4` Dismiss | `DispatchUI_Dismiss @0x006A5EB0` | `Handle_Fellowship__Dismiss @0x00569F10` | byte-identical to Quit, different notice |
|
||
| `0x01C9` FellowUpdateDone | `DispatchUI_FellowUpdateDone @0x006A5F10` | **no-op** (§2.7) | — |
|
||
| `0x01CA` FellowStatsDone | `DispatchUI_FellowStatsDone @0x006A5EE0` | **no-op** (§2.7) | — |
|
||
|
||
`SetTalkFocusEnabled(3, …)` is the chat-window fellowship-tab enable —
|
||
i.e. the `/f` channel tab lights up only while you are in a fellowship.
|
||
|
||
**`updateType` is not branched on.** `Handle_Fellowship__UpdateFellow`
|
||
passes `arg4` straight through to `SendNotice_FellowUpdated` and always
|
||
replaces the whole `Fellow` record. Only the *notice consumers*
|
||
(`gmFellowshipUI::UpdateFellowStats` vs `UpdateFellowVitals`) care which
|
||
text they refresh.
|
||
|
||
### 2.7 The two dead events — `0x01C9` / `0x01CA`
|
||
|
||
Both `ClientFellowshipSystem::Handle_Fellowship__FellowUpdateDone` and
|
||
`…__FellowStatsDone` resolve to the **same address `0x004F0EA0`** in
|
||
`symbols.json`, and BN renders that address as
|
||
`DBOCache::GetCollection(…) __pure { return 0; }` — three
|
||
identical `return 0` bodies folded by MSVC's identical-COMDAT folding.
|
||
|
||
**Conclusion: in the Sept-2013 build both events are received, validated,
|
||
their `+4` u32 is loaded, and then discarded.** ACE's own comment
|
||
("outdated message, not found in end of retail?",
|
||
`GameEventFellowshipFellowUpdateDone.cs:7`) is correct.
|
||
|
||
> **BN zero-fold caution.** The *dispatchers* do read a u32 at `arg2+4`
|
||
> (`@0x006A5F32`, `@0x006A5F02`) and pass it. Do **not** conclude the
|
||
> messages are empty — only that nothing consumes the payload. ACE
|
||
> currently writes **zero** payload bytes
|
||
> (`GameEventFellowshipFellowUpdateDone.cs:11` is commented out), so an
|
||
> acdream parser must tolerate a 0-byte body.
|
||
|
||
### 2.8 Button-enable rules — `gmFellowshipUI::UpdateButtons @0x0048E6C0`
|
||
|
||
State constants: **`1` = enabled**, **`0xD` = disabled**. Two idioms
|
||
appear and both are readable once decoded:
|
||
|
||
* `SetState(((-IsFull) & 0xC) + 1)` ⇒ `IsFull ? 0xD : 1`
|
||
* `SetState((((x == me) - 1) & 0xFFFFFFF4) + 0xD)` ⇒ `x == me ? 0xD : 1`
|
||
|
||
Rules (`m_pFellowship != 0` throughout):
|
||
|
||
| Button | Leader | Non-leader |
|
||
|---|---|---|
|
||
| Quit | `1` (always) | `1` (always) |
|
||
| Disband | `1` | `0xD` |
|
||
| Open | `1` | `0xD` |
|
||
| Leader | `1` iff `m_iidSelectedFellow != 0 && != me` | `0xD` |
|
||
| Dismiss | `1` iff `m_iidSelectedFellow != 0 && != me` | `0xD` |
|
||
| Recruit | selected world object is a player, not already a fellow, and `!IsFull` | **same, but only when `_open_fellow != 0`**; otherwise `0xD` |
|
||
|
||
Open-button caption also swaps on `_open_fellow` (`@0x0048E901`).
|
||
|
||
List-box selection (messages `3` / `0x42`, `@0x004901E5`) reads the row's
|
||
`0x1000000D` instance-ID attribute into `m_iidSelectedFellow` **and**
|
||
calls `ACCWeenieObject::SetSelectedObject` — clicking a fellow in the
|
||
panel selects them in the world (and runs the pending target-mode action
|
||
if one is armed).
|
||
|
||
Button element IDs are `0x10000274 + n` where
|
||
`n ∈ {0: Create, 7: Leader, 8: Quit, 9: Open, 0xA: Recruit, 0xB: Dismiss, 0xC: Disband}`.
|
||
|
||
### 2.9 The two client-side display computations
|
||
|
||
`gmFellowshipUI::UpdateFellowStats @0x0048EB20` writes the per-row text
|
||
element `0x10000284` with two integers: the fellow's **level** and their
|
||
**XP-share percentage ×100** (via `_ftol2` at `@0x0048ECDE`). The
|
||
percentage:
|
||
|
||
```
|
||
if (!_share_xp) pct = 0.0f
|
||
else if (_even_xp_split) pct = FellowshipSystem::GetEvenSplitXPPctg(_currNum) // §7.2 table
|
||
else pct = GetExperienceProportion(fellow._level)
|
||
/ Fellowship::CalculateExperienceProportionSum()
|
||
```
|
||
|
||
`GetExperienceProportion(L) = ExperienceSystem::ExperienceToRaiseLevel(L, L+1)`
|
||
(`@0x005B9B91`), and `CalculateExperienceProportionSum @0x005B9130`
|
||
returns 0 when `_share_xp == 0`, else the sum over all fellows.
|
||
|
||
`gmFellowshipUI::UpdateFellowVitals @0x0048ED60` refreshes only the
|
||
health/stamina/mana bars for one guid.
|
||
|
||
**These two functions are the only places the client computes anything.**
|
||
Everything else is a projection of `0x02BE`/`0x02C0`.
|
||
|
||
### 2.10 Client-side even-split recompute (display only)
|
||
|
||
`Fellowship::RecalculateEvenXPSplitting @0x005B92E0`, called from
|
||
`AddFellow @0x005B94D5`, `UpdateFellow @0x005B9785`, and
|
||
`RemoveFellow @0x005B96EE`:
|
||
|
||
```
|
||
if (_share_xp == 0) return; // leaves _even_xp_split untouched
|
||
leaderLevel = GetLeadersLevel(); // 0xFFFFFFFF if leader not in table
|
||
minLevel = 100000 (0x186A0); maxLevel = 0;
|
||
for each fellow: track min/max of _level;
|
||
_even_xp_split = 1;
|
||
if (minLevel < 50 /* 0x32 */) {
|
||
if (maxLevel > leaderLevel + 5) _even_xp_split = 0;
|
||
if (minLevel + 5 < leaderLevel) _even_xp_split = 0;
|
||
}
|
||
```
|
||
|
||
In English: **even split if every member is level 50+, or if every member
|
||
is within ±5 levels of the leader.** The retail client has **no**
|
||
"within 10 ⇒ proportional" tier — that decision is server-only, and the
|
||
client just receives `_share_xp = 0` when the spread is too wide.
|
||
|
||
### 2.11 Lock / departed
|
||
|
||
`Fellowship::HandleLockedRemoveFellow @0x005B9270` — when `_locked`,
|
||
stamps `Timer::get_real_time()` into `_fellows_departed[guid]`
|
||
(add, or remove-then-add if already present).
|
||
|
||
`Fellowship::AddFellow @0x005B9480` — when `_locked`, admits a guid only
|
||
if `_fellows_departed[guid]` exists **and**
|
||
`get_real_time() - departedTime <= 0x384` (**900 seconds = 15 minutes**,
|
||
`@0x005B94A5`).
|
||
|
||
### 2.12 Character-option accessors (all four, byte-exact)
|
||
|
||
| Option | `PlayerOption` id | Word | Bit | Mask | Accessor | Auto-save? |
|
||
|---|---|---|---|---|---|---|
|
||
| `IgnoreFellowshipRequests` | `0x02` | `options_` | 3 | `0x00000008` | `@0x005D2B30` / set `@0x005D2B40` | **yes** |
|
||
| `FellowshipShareXP` | `0x0F` | `options_` | 18 | `0x00040000` | `@0x005D2F30` / set `@0x005D2F40` | **yes** |
|
||
| `FellowshipShareLoot` | `0x11` | `options_` | 20 | `0x00100000` | `@0x005D2FD0` / set `@0x005D2FE0` | **yes** |
|
||
| `FellowshipAutoAcceptRequests` | `0x12` | `options_` | 29 | `0x20000000` | `@0x005D3020` / set `@0x005D3030` | **yes** |
|
||
|
||
(`AcceptLootPermits 0x10` sits between them and is also auto-save; ids
|
||
`0x0F`–`0x12` are all in the 21-entry `IsAutoSaveOption` table pinned in
|
||
`2026-08-10-character-options-map.md` §1.3.) All four therefore ride
|
||
**`0x0005 SetSingleCharacterOption` immediately**, never the batched
|
||
`0x01A1` blob.
|
||
|
||
Mutual exclusion, `CPlayerModule::OnChanged @0x0059A8E0`:
|
||
|
||
```
|
||
case 2: if (IgnoreFellowshipRequests()) SetFellowshipAutoAcceptRequests(0);
|
||
case 0x12: if (FellowshipAutoAcceptRequests()) SetIgnoreFellowshipRequests(0);
|
||
```
|
||
|
||
Because the clear goes through the real accessor, the **cleared** option's
|
||
own `0x0005` is emitted *before* the primary option's — acdream already
|
||
models this exactly (`RuntimeCharacterState.cs:821-834`, MF-2).
|
||
|
||
---
|
||
|
||
## 3. Wire field order — the golden-byte-vector source
|
||
|
||
**Envelope conventions.** C→S GameAction body =
|
||
`[u32 0xF7B1][u32 orderedSequence][u32 subOpcode][payload]`; S→C GameEvent
|
||
body = `[u32 0xF7B0][u32 targetGuid][u32 eventSequence][u32 eventType][payload]`.
|
||
Both are already implemented in acdream
|
||
(`SocialActions.cs:28`, `GameEventEnvelope.cs`) and are not re-derived here.
|
||
|
||
**`str16L`** = `[u16 byteLength][bytes…][pad to a 4-byte multiple counting the 2 length bytes]`,
|
||
Windows-1252. Confirmed three ways: retail
|
||
`AC1Legacy::PStringBase<char>::Pack`, ACE
|
||
`BinaryReaderExtensions.cs:41-49` (comment: *"client pads string length to
|
||
be a multiple of 4 including the 2 bytes for length"*), holtburger
|
||
`messages/utils.rs`.
|
||
|
||
**`PackableHashTable<K,V>` header** = one u32 written as
|
||
`(tableSize << 16) | currNum` (`PackableHashTable<ulong,Fellow>::Pack @0x005699C9`),
|
||
i.e. on the wire **`[u16 count][u16 numBuckets]`** little-endian, followed
|
||
by `count` × `[K key][V value]`. Confirmed by ACE
|
||
`PackableHashTable.cs:14-18` and holtburger
|
||
`messages/utils.rs:94-107`. Retail's UnPack rejects
|
||
`tableSize > 0x10000` or `count > 0x10000` (`@0x00569B1B`).
|
||
|
||
Bucket counts on the wire (**server-chosen; the client does not care**):
|
||
fellows = **16** (ACE `GameEventFellowshipFullUpdate.cs:11`, holtburger
|
||
`FELLOW_BUCKETS`), departed = **32** (ACE `Fellowship.cs:784`, holtburger
|
||
`FELLOWSHIP_LOCK_BUCKETS`), locks = **32**.
|
||
|
||
### 3.1 `0x00A2` FellowshipCreate — C→S
|
||
|
||
Retail builder `CM_Fellowship::Event_Create @0x006A67A0`; buffer size
|
||
`strPackSize + 0x10`.
|
||
|
||
| Off | Type | Field | Source |
|
||
|---|---|---|---|
|
||
| +0 | u32 | `0x000000A2` | `@0x006A6809` |
|
||
| +4 | str16L | fellowship name (post-`ACCharGenData::FormatName`) | `@0x006A6828` |
|
||
| +N | u32 | `shareXP` (0/1, from `PlayerOption 0x0F`) | `@0x006A6835` |
|
||
|
||
ACE: `GameActionFellowshipCreate.cs:11-12` — `ReadString16L()` then
|
||
`ReadUInt32() > 0`. holtburger: `actions.rs:11-20` — identical.
|
||
Chorizite: `Fellowship_Create.generated.cs:30-31` — identical
|
||
(`ReadBool()` there is a 4-byte read, `BinaryReaderExtensions.cs:178-181`).
|
||
**All four agree. There is no `openness` field.**
|
||
|
||
### 3.2 `0x00A3` FellowshipQuit — C→S
|
||
|
||
`CM_Fellowship::Event_Quit @0x006A61E0`, buffer `0x10`.
|
||
|
||
| Off | Type | Field |
|
||
|---|---|---|
|
||
| +0 | u32 | `0x000000A3` |
|
||
| +4 | u32 | `disband` (0 = quit, 1 = disband) |
|
||
|
||
ACE `GameActionFellowshipQuit.cs:9`; holtburger `actions.rs:59-68`.
|
||
|
||
### 3.3 `0x00A4` FellowshipDismiss — C→S
|
||
|
||
`Event_Dismiss @0x006A6110`, buffer `0x10`: `[u32 0xA4][u32 targetGuid]`.
|
||
ACE `GameActionFellowshipDismiss.cs:8`; holtburger `actions.rs:81-92`.
|
||
|
||
### 3.4 `0x00A5` FellowshipRecruit — C→S
|
||
|
||
`Event_Recruit @0x006A62B0`, buffer `0x10`: `[u32 0xA5][u32 targetGuid]`.
|
||
ACE `GameActionFellowshipRecruit.cs:10`; holtburger `actions.rs:35-46`.
|
||
|
||
### 3.5 `0x00A6` FellowshipUpdateRequest — C→S
|
||
|
||
`Event_UpdateRequest @0x006A6380`, buffer `0x10`:
|
||
`[u32 0xA6][u32 panelOpen]`. Sent from
|
||
`gmFellowshipUI::OnVisibilityChanged @0x0048E460` — **the argument is the
|
||
panel's visibility, not fellowship openness.** ACE
|
||
`GameActionFellowshipUpdateRequest.cs:11` (comment: *"indicates if
|
||
fellowship panel on client is visible"*); holtburger
|
||
`FellowshipUpdateRequestActionData.panel_open`.
|
||
|
||
### 3.6 `0x0290` FellowshipAssignNewLeader — C→S
|
||
|
||
`Event_AssignNewLeader @0x006A5F70`, buffer `0x10`:
|
||
`[u32 0x290][u32 newLeaderGuid]`. ACE
|
||
`GameActionFellowshipAssignNewLeader.cs:8`; holtburger `actions.rs:105-116`;
|
||
Chorizite `protocol.xml:417`.
|
||
|
||
### 3.7 `0x0291` FellowshipChangeOpenness — C→S
|
||
|
||
`Event_ChangeFellowOpeness @0x006A6040`, buffer `0x10`:
|
||
`[u32 0x291][u32 isOpen]`. ACE
|
||
`GameActionFellowshipChangeOpenness.cs:9`; Chorizite `protocol.xml:418`.
|
||
|
||
Note the client **pre-toggles its own `_open_fellow` before sending**
|
||
(`@0x0049038A`) and calls `UpdateButtons` immediately — optimistic UI. If
|
||
ACE refuses (locked fellowship, `Player_Fellowship.cs:46`) the client's
|
||
local flag is wrong until the next `0x02BE`.
|
||
|
||
### 3.8 `Fellow` — the packed member record (**the load-bearing layout**)
|
||
|
||
From `Fellow::Pack @0x005B9A10` and `Fellow::UnPack @0x005B9AD0`.
|
||
`Fellow::GetPackSize @0x005B99F0` returns `0x28 + strPackSize` — 0x28 =
|
||
40 = exactly ten u32s.
|
||
|
||
| Off | Type | Field | Pack site |
|
||
|---|---|---|---|
|
||
| +0x00 | u32 | `_cp_cache` (undistributed XP) | `@0x005B9A31` |
|
||
| +0x04 | u32 | `_lum_cache` (undistributed luminance) | `@0x005B9A3F` |
|
||
| +0x08 | u32 | `_level` | `@0x005B9A4B` |
|
||
| +0x0C | u32 | `_max_health` | `@0x005B9A59` |
|
||
| +0x10 | u32 | `_max_stamina` | `@0x005B9A65` |
|
||
| +0x14 | u32 | `_max_mana` | `@0x005B9A73` |
|
||
| +0x18 | u32 | `_current_health` | `@0x005B9A7F` |
|
||
| +0x1C | u32 | `_current_stamina` | `@0x005B9A8D` |
|
||
| +0x20 | u32 | `_current_mana` | `@0x005B9A99` |
|
||
| +0x24 | u32 | `_share_loot` | `@0x005B9AA7` |
|
||
| +0x28 | str16L | `_name` | `@0x005B9AB5` |
|
||
|
||
**The GUID is not part of `Fellow`** — inside `0x02BE` it is the
|
||
hash-table key written immediately before the value
|
||
(`PackableHashTable<…>::Pack @0x005699F9`), and inside `0x02C0` it is an
|
||
explicit leading u32 (§3.10). Either way, on the wire the sequence is
|
||
always `[u32 guid][40 bytes][str16L name]` = **44 fixed bytes + name**,
|
||
exactly holtburger's `FellowshipMemberData::unpack`
|
||
(`events.rs:44-78`, `*offset + 44`).
|
||
|
||
`_share_loot` is a **raw u32 that the client stores verbatim**
|
||
(`@0x005B9B5C`) and never interprets — see §4.1 for why that matters.
|
||
|
||
### 3.9 `0x02BE` FellowshipFullUpdate — S→C
|
||
|
||
Body = `Fellowship::Pack`. Retail reads it back at
|
||
`Fellowship::UnPack @0x005B94F0` in exactly this order:
|
||
|
||
| # | Type | Field | UnPack site |
|
||
|---|---|---|---|
|
||
| 1 | `PackableHashTable<u32, Fellow>` | members — `[u16 count][u16 buckets=16]` then `count` × (`[u32 guid]` + §3.8) | `@0x005B951D` |
|
||
| 2 | str16L | fellowship name | `@0x005B9544` |
|
||
| 3 | u32 | leader guid | `@0x005B9559` |
|
||
| 4 | u32 | `_share_xp` | `@0x005B957E` |
|
||
| 5 | u32 | `_even_xp_split` | `@0x005B957E` |
|
||
| 6 | u32 | `_open_fellow` | `@0x005B957E` |
|
||
| 7 | u32 | `_locked` | `@0x005B957E` |
|
||
| 8 | `PackableHashTable<u32, i32>` | departed — `[u16 count][u16 buckets=32]` then `count` × `[u32 guid][i32 timestamp]` | `@0x005B95D6` |
|
||
| **9** | `PackableHashTable<str16L, FellowshipLockData>` | **locks — sent by the server, NOT read by the 2013 client** | — |
|
||
|
||
Field 9 exists on the wire: ACE writes it (`GameEventFellowshipFullUpdate.cs:42`),
|
||
Chorizite's pcap-derived schema has it
|
||
(`Types/Fellowship.generated.cs:78`), and holtburger parses it
|
||
(`events.rs:233-237`). `FellowshipLockData` = five u32s
|
||
(`unknown1, unknown2, unknown3, timestamp, sequence`) — Chorizite
|
||
`FellowshipLockData.generated.cs:42-48`, holtburger `events.rs:139-160`.
|
||
|
||
**`Fellowship::UnPack` stops after field 8** and returns success; retail
|
||
simply ignores the trailing lock table. An acdream parser should read all
|
||
nine (the GameEvent framing gives the length anyway) so the locks are
|
||
available for §2.11 display, but must **not** fail when field 9 is absent.
|
||
|
||
### 3.10 `0x02C0` FellowshipUpdateFellow — S→C
|
||
|
||
`CM_Fellowship::DispatchUI_UpdateFellow @0x006A6700`:
|
||
|
||
| Off | Type | Field | Site |
|
||
|---|---|---|---|
|
||
| +0 | u32 | `0x02C0` | `@0x006A6726` |
|
||
| +4 | u32 | **member guid** | `@0x006A673B` |
|
||
| +8 | 40 B + str16L | `Fellow` (§3.8) | `@0x006A6763` |
|
||
| … | u32 | `updateType` | `@0x006A676C` |
|
||
|
||
`updateType` values (Chorizite `FellowUpdateType.generated.cs:18-22`,
|
||
holtburger `events.rs:14-19`, ACE `FellowUpdateType`):
|
||
`0 Undef` / `1 Full` / `2 UpdateStats` / `3 UpdateVitals`.
|
||
ACE only ever sends `1` (`Fellowship.cs:166`, default parameter) and
|
||
`3` (`Fellowship.cs:724`).
|
||
|
||
> **Reference disagreement, resolved.** Chorizite's generated
|
||
> `Fellowship_UpdateFellow` is **missing the leading guid**
|
||
> (`protocol.xml:7807-7810` declares only `Fellow` + `FellowUpdateType`,
|
||
> and `Types/Fellow.generated.cs:82` starts at `XPCached`). Retail
|
||
> (`@0x006A673B`) and ACE (`GameEventFellowshipUpdateFellow.cs:13`) and
|
||
> holtburger (`events.rs:49`) all put the guid first. **Retail wins —
|
||
> use guid-first.**
|
||
|
||
### 3.11 `0x02BF` FellowshipDisband — S→C
|
||
|
||
`[u32 0x02BF]` and nothing else. `DispatchUI_Disband @0x006A5E80` reads
|
||
only the opcode; ACE `GameEventFellowshipDisband.cs:5-9` writes no body.
|
||
|
||
### 3.12 `0x00A3` FellowshipQuit — S→C
|
||
|
||
`[u32 0x00A3][u32 quitterGuid]`. `DispatchUI_Quit @0x006A5F5F` reads
|
||
`arg2+4`. ACE `GameEventFellowshipQuit.cs:8`. Sent to the quitter
|
||
(`Fellowship.cs:325`) **and** to every remaining member
|
||
(`Fellowship.cs:332`) — the client's handler distinguishes by comparing
|
||
the guid to `SmartBox::smartbox->player_id`.
|
||
|
||
### 3.13 `0x00A4` FellowshipDismiss — S→C
|
||
|
||
`[u32 0x00A4][u32 dismissedGuid]`. `DispatchUI_Dismiss @0x006A5EC9`.
|
||
ACE `GameEventFellowshipDismiss.cs:11` (its comment *"can be both S2C and
|
||
C2S?"* is correct — the same numeric value is used in both directions,
|
||
in different envelopes).
|
||
|
||
### 3.14 `0x01C9` / `0x01CA` — S→C, dead
|
||
|
||
`[u32 opcode][u32 payload]` per the dispatchers, but see §2.7: the client
|
||
discards them and ACE writes a zero-length body. **Parse-and-ignore.**
|
||
|
||
### 3.15 Confirmation triple (shared, `ConfirmationType = 4`)
|
||
|
||
| Dir | Opcode | Layout | Anchors |
|
||
|---|---|---|---|
|
||
| S→C | `0x0274` | `[u32 type][u32 context][str16L text]` | `DispatchUI_ConfirmationRequest @0x006A2D90` (reads +4, +8, then `PStringBase::UnPack` from +0xC); ACE `GameEventConfirmationRequest.cs:10-12` |
|
||
| C→S | `0x0275` | `[u32 type][u32 context][u32 response]` | `Event_ConfirmationResponse @0x006A1210` (buffer `0x18`; writes `@0x006A126A`, `@0x006A127B`, `@0x006A12B5`, `@0x006A12F3`); ACE `GameActionConfirmationResponse.cs:11-13` |
|
||
| S→C | `0x0276` | `[u32 type][u32 context]` | `DispatchUI_ConfirmationDone @0x006A0E4A`; ACE `GameEventConfirmationDone.cs` |
|
||
|
||
`ConfirmationType` values, retail
|
||
`Handle_Character__ConfirmationRequest @0x005640A0` switch vs ACE
|
||
`ConfirmationType.cs:5-12` — **identical**: `1 SwearAllegiance`,
|
||
`2 AlterSkill`, `3 AlterAttribute`, **`4 Fellowship`**, `5 CraftInteraction`,
|
||
`6 Augmentation`, `7 Yes_No`.
|
||
|
||
### 3.16 `0x0005` SetSingleCharacterOption (the four option bits)
|
||
|
||
`[u32 0x0005][u32 playerOptionId][u32 value]` — already shipped in acdream
|
||
(`SocialActions.cs:56`, `WorldSession.SendSetSingleCharacterOption`).
|
||
Ids `0x02`, `0x0F`, `0x11`, `0x12` per §2.12.
|
||
|
||
---
|
||
|
||
## 4. ACE caveats — where the server diverges or does not serve
|
||
|
||
Every claim here is pinned to an ACE source line. **Do not assume ACE
|
||
matches retail; these are the places it does not.**
|
||
|
||
### 4.1 `shareLoot` is encoded two different ways, and neither is 1
|
||
|
||
| Message | ACE writes | Line |
|
||
|---|---|---|
|
||
| `0x02BE` FullUpdate, per fellow | **constant `0x10`** (comment: *"if 0 then noSharePhatLoot, if 16 (0x0010) then sharePhatLoot"*) — the fellow's actual permission is never consulted | `GameEventFellowshipFullUpdate.cs:63` |
|
||
| `0x02C0` UpdateFellow | `Convert.ToUInt32(shareLoot) << 1` ⇒ **0 or 2** | `GameEventFellowshipUpdateFellow.cs:25` |
|
||
|
||
So ACE reports `0x10` for **every** fellow in a full update regardless of
|
||
setting, and `0`/`2` in incremental updates. Chorizite's `Fellow.ShareLoot`
|
||
is `ReadBool()` = `ReadInt32() == 1`
|
||
(`BinaryReaderExtensions.cs:178-181`), which evaluates **both** ACE
|
||
encodings to `false`.
|
||
|
||
Retail stores the raw u32 verbatim (`@0x005B9B5C`) and — in the 2013
|
||
build — never reads `_share_loot` for any decision;
|
||
`Fellowship::UpdateFellow`/`operator=` only copy it
|
||
(`@0x005B9923`, `@0x005B99B6`).
|
||
|
||
**Recommendation for acdream: model `shareLoot` as a raw `uint`, and
|
||
treat `!= 0` as "shares".** Never `== 1`. Never round-trip it as a bool.
|
||
File the ACE inconsistency; it is the kind of thing a future
|
||
golden-byte-vector test will trip over.
|
||
|
||
### 4.2 Fellowship-level truth ACE does serve
|
||
|
||
`ShareXP`, `EvenShare`, `Open`, `IsLocked`, `DepartedMembers`,
|
||
`FellowshipLocks` are all real and all written
|
||
(`GameEventFellowshipFullUpdate.cs:32-42`). `cpCached` and `lumCached` are
|
||
**hard-coded 0 with a TODO** in both messages
|
||
(`GameEventFullUpdate.cs:49-50`, `GameEventUpdateFellow.cs:16-17`) — so
|
||
those two panel columns will read zero against ACE forever, which is a
|
||
*server* gap, not an acdream one.
|
||
|
||
### 4.3 The even-split share table — ACE's 9-fellow row is wrong
|
||
|
||
| fellows | retail (byte-decoded, §7.2) | retail total multiplier | ACE `GetMemberSharePercent()` (`Fellowship.cs:604-632`) |
|
||
|---:|---|---|---|
|
||
| 1 | 1.0 | 1.00 | 1.0 ✔ |
|
||
| 2 | 0.75 | 1.50 | .75 ✔ |
|
||
| 3 | 0.6 | 1.80 | .6 ✔ |
|
||
| 4 | 0.55 | 2.20 | .55 ✔ |
|
||
| 5 | 0.5 | 2.50 | .5 ✔ |
|
||
| 6 | 0.45 | 2.70 | .45 ✔ |
|
||
| 7 | 0.4 | 2.80 | .4 ✔ |
|
||
| 8 | 0.35 | 2.80 | .35 ✔ |
|
||
| **9** | **0.3111111** | **2.80** | **.3** ✘ (total 2.70) |
|
||
| 10 | 0.28 | 2.80 | *(no case — falls through to 1.0)* ✘ |
|
||
| 0 or >10 | **0.0** | — | 1.0 ✘ |
|
||
|
||
Retail's design is transparent once the totals are written out: **the
|
||
fellowship XP multiplier caps at exactly 2.8× from 7 members up.** ACE's
|
||
`.3` breaks that cap at 9 (a 3.6 % XP shortfall for a full fellowship),
|
||
and its out-of-range default of `1.0` is the opposite of retail's `0.0`.
|
||
|
||
This is a **server** bug. It does not block acdream, but the client's own
|
||
panel percentage (§2.9) will disagree with the XP the server actually
|
||
grants — so any parity gate that compares "panel says 31.1 %" against
|
||
"ACE granted 30 %" is measuring ACE, not us.
|
||
|
||
### 4.4 Locked-fellowship re-recruit window: 900 s vs 600 s
|
||
|
||
Retail: `Fellowship::AddFellow @0x005B94A5` — `<= 0x384` = **900 s**.
|
||
ACE: `Fellowship.cs:93` — `AddSeconds(600)`.
|
||
ACE's own broadcast text says **15 minutes**
|
||
(`Fellowship.cs:436`: *"If you leave the fellowship, you have 15 minutes
|
||
to be recruited back into the fellowship."*). ACE's message and ACE's code
|
||
disagree; retail agrees with ACE's message.
|
||
|
||
Client-side impact is nil (the server decides recruitment), but if acdream
|
||
ever renders a countdown from `_fellows_departed`, use **900**.
|
||
|
||
### 4.5 No panel-open request ⇒ no vitals
|
||
|
||
`Fellowship.OnVitalUpdate` only sends `0x02C0` to fellows whose
|
||
`FellowshipPanelOpen` is true (`Fellowship.cs:723`), and that flag is set
|
||
**only** by `0x00A6` (`Player_Fellowship.cs:142-148`). An acdream client
|
||
that never sends `0x00A6` will see a static roster: correct names, levels
|
||
and max-vitals from `0x02BE`, but current health/stamina/mana frozen at
|
||
join time. **This makes feature 12 a prerequisite for feature 13, not a
|
||
nicety.**
|
||
|
||
The upstream driver is the `FellowVitalUpdate` latch consumed once per
|
||
player tick (`Player_Tick.cs:84-89`), so the cadence is per-tick-coalesced,
|
||
not per-damage-event.
|
||
|
||
### 4.6 Things ACE does not implement or does not serve
|
||
|
||
| Gap | Evidence |
|
||
|---|---|
|
||
| **No fellowship-name validation.** `FellowshipNameIsNotPermitted (0x041F)` is defined but never sent. | `grep FellowshipNameIsNotPermitted` over `ACE.Server` returns only the enum. Retail formats the name client-side via `ACCharGenData::FormatName` (§2.2), so retail may have relied on that plus a server check we cannot see. |
|
||
| **No client-driven lock/unlock.** `HandleActionFellowshipChangeLock` exists (`Player_Fellowship.cs:50-54`) but has **no `GameAction` mapped to it** — only `EmoteManager` reaches it. ACE's own comment: *"Unlocking a fellowship is not possible without disbanding in retail worlds"* (`Fellowship.cs:420`). | Retail also has no lock button in `gmFellowshipUI` (§2.1 control list). Consistent. |
|
||
| **`FellowshipMaxDistanceExceeded (0x0419)`, `FellowshipSquelched (0x0418)`, `FellowshipIllegalLevel (0x041B)`, `FellowshipMember (0x041A)`, `FellowshipTimeout (0x04DC)` are never sent.** Recruit-busy is a plain chat line, not `FellowshipRecruitBusy (0x041C)` (`Fellowship.cs:117`). | `Fellowship.cs` / `Player_Fellowship.cs` send only `FellowshipIgnoringRequests`, `YouMustBeLeaderOfFellowship`, `YourFellowshipIsFull`, `FellowshipDeclined`, `FellowshipIsLocked`, `OlthoiCannotJoinFellowship`, `LockedFellowshipCannotRecruit_`, `_IsNowOpenFellowship`/`_IsNowClosedFellowship`/`_IsNowLeaderOfFellowship`/`YouHavePassedFellowshipLeadershipTo_`. |
|
||
| **Leader-drop reassignment is random.** `AssignNewLeader(null, null)` picks `ThreadSafeRandom` over the member list (`Fellowship.cs:396-400`). Retail's client pre-empts this by sending `0x0290` on Quit (§2.5); on a *crash* disconnect it does not. | — |
|
||
| **A logoff is a permanent quit.** `LogOut_Inner` calls `FellowshipQuit(false)` (`Player.cs:531-532`) — you are not re-added on login. `IsLocked` gives a 10-minute (ACE) / 15-minute (retail) re-recruit grace, nothing more. | — |
|
||
| **`GetFellowshipMembers()` silently drops offline members** and logs `"Dropped fellow: …"`, then reassigns leadership and broadcasts a full update (`Fellowship.cs:739-779`). Expect unsolicited `0x02BE` bursts. | — |
|
||
| **Proximity for luminance uses radar range, for XP uses 600/1200 m.** Two different distance models in the same class. | `Fellowship.cs:592` (`WithinRange`, 75 m outdoor / 25 m indoor, `Player.cs:73-78`) vs `Fellowship.cs:634-668` (`MaxDistance = 600`). `fellow_kt_landblock` (default **false**, `PropertyManager.cs:554`) switches `WithinRange` to a 192 m / same-landblock rule. |
|
||
| **Config knobs that change observable behaviour**: `fellow_busy_no_recruit` (default **true**, "different from retail"), `fellow_quest_bonus` (default **false**), `fellowship_even_share_level` (default **50** — matches retail's hard-coded `0x32`). | `PropertyManager.cs:552,555,623` |
|
||
|
||
---
|
||
|
||
## 5. acdream today
|
||
|
||
### 5.1 The complete inventory
|
||
|
||
| Area | File:line | State |
|
||
|---|---|---|
|
||
| S→C opcode enum (all 7) | `src/AcDream.Core.Net/Messages/GameEventType.cs:29,30,56,57,100,101,102` | **enum-only** |
|
||
| C→S opcode constants (5 of 7) | `src/AcDream.Core.Net/Messages/SocialActions.cs:37-41` | **builder-only**; `0x0290`/`0x0291` **absent** |
|
||
| Outbound builders | `SocialActions.cs:123,141,152,156,160` | **no production caller** — `grep BuildFellowship` finds only the definitions and `tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs:53-105` |
|
||
| Inbound parsing | — | **none.** `GameEventWiring.WireAll` registers ~40 event types; **zero** fellowship |
|
||
| Fellowship state class | — | **none.** (`src/AcDream.Core/Social/` holds only `FriendsState.cs` + `SquelchState.cs` — the precedent shape) |
|
||
| Option `IgnoreFellowshipRequests` | `CharacterOptionTable.cs:113` (`0x02`, `0x00000008`, autoSave, default **on**) | **live** storage + wire; store-only UI |
|
||
| Option `FellowshipShareXP` | `CharacterOptionTable.cs:126` (`0x0F`, `0x00040000`, autoSave, default **on**) | same |
|
||
| Option `FellowshipShareLoot` | `CharacterOptionTable.cs:128` (`0x11`, `0x00100000`, autoSave, default off) | same |
|
||
| Option `FellowshipAutoAcceptRequests` | `CharacterOptionTable.cs:129` (`0x12`, `0x20000000`, autoSave, default off) | same |
|
||
| Option mutual exclusion (MF-2) | `RuntimeCharacterState.cs:794-843` | **live and retail-exact** |
|
||
| Fellowship **chat** | `ChatInputParser.cs:87-93`, `ChannelResolver.cs:39`, `LegacyChannelChatType.cs:78`, `RetailChatColorTable` `0x13` | **LIVE** |
|
||
| Confirmation transport | `GameEventWiring.cs:202,212` (`0x0274`, `0x0276`), `GameplayConfirmationController.cs:37` | `0x0274`/`0x0276` parsed; **no type-4 branch** |
|
||
| WeenieError enum | `src/AcDream.Core/Physics/WeenieError.cs:309-317,453-454,485-501,553` | **complete** for fellowship |
|
||
| WeenieError display text | `src/AcDream.Core/Chat/WeenieErrorMessages.cs:257-259,446-450,465-466,480,596` | **11 of 19 present.** Missing: `0x0417`, `0x0418`, `0x0419`, `0x041A`, `0x041B`, `0x041C`, `0x04DB`, `0x04DC` — including **`0x0417 FellowshipIgnoringRequests` and `0x04DB FellowshipDeclined`, both of which ACE actually sends** (`Player_Fellowship.cs:101`, `Fellowship.cs:148`) |
|
||
| Radar seam | `RadarBlipColors.cs:58-61,168-171`, `RetailRadar.cs:30-31,107-111` | seam exists, `IsFellowshipMember`/`IsFellowshipLeader` hard-wired `false` — **AP-90** |
|
||
| Input actions | `InputAction.cs:86,88,106`; `KeyBindings.cs:201,202,210` (N / M / F4); `RetailActionIdentityTable.cs:186,187,194` | declared, bound, **no consumers** |
|
||
| Panel | — | **none.** `docs/research/retail-ui/05-panels.md:702-760` has the §9 design sketch |
|
||
|
||
### 5.2 The two builder defects (both currently latent — no callers)
|
||
|
||
**(a) `BuildFellowshipCreate` invents a field and sends the wrong flag.**
|
||
`SocialActions.cs:123-138` writes the name, then **two single bytes**
|
||
`openness` and `shareXp`:
|
||
|
||
```csharp
|
||
body[12 + name.Length] = openness ? (byte)1 : (byte)0;
|
||
body[12 + name.Length + 1] = shareXp ? (byte)1 : (byte)0;
|
||
```
|
||
|
||
Retail/ACE/holtburger/Chorizite all expect exactly one trailing `u32
|
||
shareXP` (§3.1). Because the padding arithmetic happens to produce the
|
||
same total length, the message is *accepted* by ACE — it just reads the
|
||
`openness` byte as the low byte of `shareXP`. With
|
||
`(openness: true, shareXp: false)`, ACE stores `ShareXP = true`.
|
||
**Silently inverted, not rejected.** The test at
|
||
`SocialActionsTests.cs:53-68` pins the wrong shape
|
||
(`Assert.Equal(1, body[20]); Assert.Equal(0, body[21]);`) and must be
|
||
rewritten alongside the fix.
|
||
|
||
The `// string16L name, bool openness, bool shareXP` comment at
|
||
`SocialActions.cs:37` is the origin of the error.
|
||
|
||
**(b) `BuildFellowshipUpdate(uint seq, bool open)` names the wrong
|
||
feature.** `0x00A6` is `FellowshipUpdateRequest` — *panel visibility*
|
||
(§3.5). Openness is `0x0291`, which acdream does not have. The comment at
|
||
`SocialActions.cs:41` (`// bool open`) and the method name are both wrong;
|
||
the *bytes* are correct for a panel-visibility request. Test
|
||
`SocialActionsTests.cs:98-105` pins the byte, not the meaning, so it
|
||
survives a rename.
|
||
|
||
Nothing else is wrong: `Quit`, `Dismiss`, `Recruit` are byte-correct
|
||
(the 1-byte writes into zeroed 16-byte buffers produce the same LE u32 as
|
||
retail's full-word writes).
|
||
|
||
---
|
||
|
||
## 6. Implement vs store — recommendation
|
||
|
||
### 6.1 What to implement (in dependency order)
|
||
|
||
1. **`FellowshipState` in `AcDream.Core/Social/`** — mirror
|
||
`FriendsState.cs`. Fields exactly as §3.9, members keyed by guid with
|
||
the §3.8 record. `shareLoot` as raw `uint`, not `bool` (§4.1).
|
||
2. **Parsers + `GameEventWiring` registrations** for `0x02BE`, `0x02C0`,
|
||
`0x02BF`, `0x00A3`, `0x00A4`; parse-and-ignore `0x01C9`/`0x01CA`.
|
||
The dispatcher seam already exists — `GameEventWiring.WireAll`
|
||
(`src/AcDream.Core.Net/GameEventWiring.cs`).
|
||
3. **Fix the two builders** (§5.2) and add `0x0290` + `0x0291`.
|
||
4. **Confirmation type-4 branch** — smallest user-visible slice; the
|
||
`0x0274`/`0x0275`/`0x0276` transport is already shipped. Must consult
|
||
`FellowshipAutoAcceptRequests` so no popup appears when ACE
|
||
auto-accepted (§1, feature 6).
|
||
5. **`0x00A6` on panel show/hide** — a prerequisite for live vitals (§4.5).
|
||
6. **The panel** (`gmFellowshipUI`), with the §2.8 button rules and the
|
||
§2.9 percentage text.
|
||
7. **Radar markers** (AP-90) and the N/M/F4 input consumers — both are
|
||
pure consumers of step 1 with no new mechanism.
|
||
8. **The 8 missing WeenieError strings** (§5.1) — `0x0417` and `0x04DB`
|
||
are on ACE's real send paths, so they are user-visible gaps today.
|
||
|
||
### 6.2 What to store, not compute
|
||
|
||
* **`_even_xp_split` and `_share_xp`**: take the wire value. The retail
|
||
client's `RecalculateEvenXPSplitting` (§2.10) is a *local optimistic
|
||
recompute for display between updates*, and it deliberately implements
|
||
only two of the server's three tiers. Re-deriving it as authority would
|
||
make acdream disagree with ACE whenever the leader's level is between
|
||
5 and 10 above the lowest fellow. **Port it, if at all, only as the
|
||
display-smoothing it is — and never let it override a `0x02BE`.**
|
||
* **Distance/proximity scalars (§4.6)**: entirely server-side. There is
|
||
no retail client function for them. Do not model.
|
||
* **The even-split share table (§7.2)**: needed *only* for the panel's
|
||
per-fellow percentage text. It is not an XP calculation.
|
||
* **`IsFull` (`>= 9`)**: client-side button gating only. The server
|
||
re-checks twice (`Fellowship.cs:102`, `:151`).
|
||
* **The four character options**: already correct. ACE is the consumer of
|
||
all four; acdream's only job is to send the bit, which it does. They
|
||
stay AD-78-dimmed until/unless the fellowship panel gives
|
||
`FellowshipShareXP` a *client-visible* consumer (the Create flow reads
|
||
it — see the un-dim note in §8/U6).
|
||
|
||
---
|
||
|
||
## 7. BN zero-fold and decomp-artifact warnings
|
||
|
||
Per the lane brief: **flag literal-0 operands hiding real values rather
|
||
than asserting them.** Three appear in this feature; two are resolved by
|
||
byte-decode below, one is resolved by reading the caller.
|
||
|
||
### 7.1 `Fellowship::IsFull @0x005B9070` — BN says `return 1`
|
||
|
||
BN renders the body as `return ((eax - eax) + 1);` — i.e. constant 1.
|
||
acclient.c (the second decompiler) fares no better
|
||
(`acclient.c:835878`, garbage). **Both are wrong: it is a folded `sbb`
|
||
idiom.** Byte-decoded from the PDB-paired binary at file offset
|
||
`0x1B9070` (VA − ImageBase, `.text` VA `0x00401000` / raw `0x1000`):
|
||
|
||
```
|
||
83 79 14 09 cmp dword ptr [ecx+0x14], 9
|
||
1b c0 sbb eax, eax
|
||
40 inc eax
|
||
c3 ret
|
||
```
|
||
|
||
`[ecx+0x14]` is `Fellowship._fellowship_table._currNum`
|
||
(`Fellowship` = vtable +0, then `PackableHashTable` at +4 whose
|
||
`_currNum` sits at +0x14). `sbb eax,eax` yields −1 when the borrow flag
|
||
is set (`_currNum < 9`), 0 otherwise; `inc eax` maps that to 0/1.
|
||
|
||
**`IsFull() ⇔ _currNum >= 9`. Max fellowship = 9, byte-verified.**
|
||
|
||
### 7.2 `FellowshipSystem::GetEvenSplitXPPctg @0x005B9BA0` — BN says every case returns `arg1 - 1`
|
||
|
||
BN renders all ten switch cases as `return (arg1 - 1);` and types the
|
||
return `int32_t`. **Both are wrong.** The real function returns a
|
||
**float on the x87 stack** loaded from `.rdata`; BN elided the `fld`
|
||
targets. Byte-decoded:
|
||
|
||
```
|
||
8b 44 24 04 mov eax, [esp+4] ; numFellows
|
||
48 dec eax
|
||
83 f8 09 cmp eax, 9
|
||
77 4d ja default ; → 0x005B9BF7
|
||
ff 24 85 00 9c 5b 00 jmp dword ptr [eax*4 + 0x005B9C00]
|
||
…
|
||
005B9BB1: d9 05 b0 28 79 00 c3 fld dword [0x007928B0]; ret ; n=1
|
||
005B9BB8: d9 05 dc b6 79 00 c3 fld dword [0x0079B6DC]; ret ; n=2
|
||
005B9BBF: d9 05 c4 48 79 00 c3 fld dword [0x007948C4]; ret ; n=3
|
||
005B9BC6: d9 05 c0 72 7e 00 c3 fld dword [0x007E72C0]; ret ; n=4
|
||
005B9BCD: d9 05 b8 28 79 00 c3 fld dword [0x007928B8]; ret ; n=5
|
||
005B9BD4: d9 05 d4 91 7c 00 c3 fld dword [0x007C91D4]; ret ; n=6
|
||
005B9BDB: d9 05 f0 a9 7c 00 c3 fld dword [0x007CA9F0]; ret ; n=7
|
||
005B9BE2: d9 05 bc 72 7e 00 c3 fld dword [0x007E72BC]; ret ; n=8
|
||
005B9BE9: d9 05 b8 72 7e 00 c3 fld dword [0x007E72B8]; ret ; n=9
|
||
005B9BF0: d9 05 b4 72 7e 00 c3 fld dword [0x007E72B4]; ret ; n=10
|
||
005B9BF7: d9 05 44 53 79 00 c3 fld dword [0x00795344]; ret ; default
|
||
005B9C00: jump table — b1/b8/bf/c6/cd/d4/db/e2/e9/f0 9b 5b 00 (10 entries)
|
||
```
|
||
|
||
Section map used for VA→file-offset (`.rdata` VA `0x00792000` / raw
|
||
`0x00392000`), floats read directly:
|
||
|
||
| n | VA | float |
|
||
|---:|---|---|
|
||
| 1 | `0x007928B0` | **1.0** |
|
||
| 2 | `0x0079B6DC` | **0.75** |
|
||
| 3 | `0x007948C4` | **0.6** |
|
||
| 4 | `0x007E72C0` | **0.55** |
|
||
| 5 | `0x007928B8` | **0.5** |
|
||
| 6 | `0x007C91D4` | **0.45** |
|
||
| 7 | `0x007CA9F0` | **0.4** |
|
||
| 8 | `0x007E72BC` | **0.35** |
|
||
| 9 | `0x007E72B8` | **0.3111111** |
|
||
| 10 | `0x007E72B4` | **0.28** |
|
||
| default (0 or >10) | `0x00795344` | **0.0** |
|
||
|
||
Used live at `gmFellowshipUI::UpdateFellowStats @0x0048EC2C` — this is a
|
||
**shipped display path**, not dead code.
|
||
|
||
### 7.3 The `SetState` folds in `UpdateButtons`
|
||
|
||
`SetState((((eax - eax) & 0xc) + 1))` and
|
||
`SetState((((ebx - 1) & 0xfffffff4) + 0xd))` are the same `sbb`/`setcc`
|
||
folding class. Decoded in §2.8: they are `cond ? 0xD : 1`. **Do not read
|
||
these as constants.**
|
||
|
||
### 7.4 Non-folds that still need care
|
||
|
||
* `Fellowship::GetLeadersLevel @0x005B91B0` genuinely returns
|
||
`0xFFFFFFFF` when the leader is not in the member table — that is a
|
||
real sentinel, not an artifact. `RecalculateEvenXPSplitting` then
|
||
compares `maxLevel > 0xFFFFFFFF + 5` in unsigned arithmetic; treat a
|
||
leaderless table as "leave `_even_xp_split` at 1".
|
||
* `PackableHashData<K,V>` field offsets in the pseudo-C appear as raw
|
||
indices (`i[0xd]` = `_next`, `i[0xe]` = `_hashVal`, `+0xc` = `_level`).
|
||
These are correct but unnamed — cross-check against
|
||
`acclient.h:36663-36670` before trusting any new offset.
|
||
* `HandleLockedRemoveFellow @0x005B9270` shows BN calling three
|
||
*differently-typed* `PackableHashTable` template instantiations
|
||
(`<u32,u32>::lookup`, `<IDClass,int>::add`, `<u32,int>::remove`) on the
|
||
same member. That is COMDAT folding of identical template code, not
|
||
three different tables.
|
||
|
||
---
|
||
|
||
## 8. Unknowns for the coordinator
|
||
|
||
**U1 — `_fellows_departed` timestamp epoch.** Retail's client writes
|
||
`Timer::get_real_time()` (`@0x005B929D`) and compares against it
|
||
(`@0x005B94A5`), while ACE writes `Time.GetUnixTime()`
|
||
(`Fellowship.cs:318`). Since the client's whole table is overwritten by
|
||
every `0x02BE` (`Fellowship::operator=`), the client would be comparing
|
||
server Unix seconds against its own clock — which would make its local
|
||
`AddFellow` gate meaningless. **Unresolved:** whether
|
||
`Timer::get_real_time()` is wall-clock or session-relative. Consequence
|
||
is confined to rendering a departed-member countdown; it does **not**
|
||
affect any wire field. Resolve by cdb-tracing `Timer::get_real_time`
|
||
against a known wall clock, or by declining to render a countdown.
|
||
|
||
**U2 — the layout DAT id for `gmFellowshipUI`.** Not found in
|
||
`docs/research/retail-ui/` (which has the panel's *content* at
|
||
`05-panels.md:702-760` but no `LayoutDesc` id) and not derivable from the
|
||
decomp alone. **The element ids are known** (`0x10000274 + n` for buttons,
|
||
`0x1000000D` for the row instance-ID attribute, `0x10000284` for the
|
||
per-row stats text, `0x10000001` for the string table) — but the panel's
|
||
own LayoutDesc id needs a DAT sweep, same method as the Options panel's
|
||
`0x2100002B`. **Blocking for the panel slice, not for the wire slice.**
|
||
|
||
**U3 — `FellowshipLockData`'s three unknown u32s.** Named
|
||
`unknown_1/2/3` by both Chorizite
|
||
(`FellowshipLockData.generated.cs:19-33`) and holtburger
|
||
(`events.rs:132-134`), and the 2013 client never reads them (§3.9). ACE
|
||
writes a 2-field `FellowshipLockData` (`Fellowship.cs:434`) — **the ACE
|
||
struct and the pcap struct may not be the same width**, which would
|
||
desynchronise a strict parser. Recommend parsing the locks table
|
||
**last** and tolerating a short/absent tail until a real capture settles
|
||
it.
|
||
|
||
**U4 — Quit ordering under ACE.** Retail's leader-Quit emits `0x0290`
|
||
then `0x00A3` back-to-back (§2.5). ACE's `QuitFellowship` *also* calls
|
||
`AssignNewLeader(null, null)` (random) at `Fellowship.cs:340`. If the two
|
||
GameActions are processed in order the client's choice wins; the
|
||
`0x0290` handler warns and bails if the sender is no longer leader
|
||
(`Player_Fellowship.cs:117-121`). **Needs a two-client connected gate**
|
||
to confirm no warning fires and the intended fellow gets leadership.
|
||
|
||
**U5 — does ACE ever send `updateType = 2 (UpdateStats)`?** No call site
|
||
found; only `Full` (default) and `Vitals`. Retail's client has a distinct
|
||
`UpdateFellowStats` renderer. Not blocking (retail patches the whole
|
||
record regardless), but a parity gate should not expect a `2`.
|
||
|
||
**U6 — AD-78 dimming for `FellowshipShareXP` after the panel lands.**
|
||
`CharacterOptionsPageController.cs:198` classifies `0x0F` as Group D
|
||
(store-only). Once acdream implements Create (§2.2 reads the bit to build
|
||
`0x00A2`), that row acquires a genuine **client-side** consumer and must
|
||
be flipped to `Live` in **both**
|
||
`src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs:198` and
|
||
`tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs`
|
||
(`ExpectedStoreOnlyIds`) — the test pins the set exactly and will fail
|
||
otherwise. `0x12 FellowshipAutoAcceptRequests` gets the same treatment if
|
||
the confirmation branch reads it (§6.1 step 4);
|
||
`CharacterOptionsPageController.cs:147-154` already flags that row's
|
||
classification as "a one-line judgment call, not a re-investigation".
|
||
`0x02` and `0x11` stay dimmed — ACE remains their only consumer.
|
||
|
||
**U7 — golden byte vectors.** holtburger ships real fixtures
|
||
(`crates/holtburger-protocol/src/test_fixtures.rs`, exercised by
|
||
`events.rs`'s `test_fellowship_full_update_fixture` with a 2-member
|
||
fellowship, `share_loot: 0x10`). **These are the cheapest source of
|
||
conformance vectors for an acdream parser** and are pack/unpack-parity
|
||
checked on the Rust side. I did not extract the byte arrays; if the
|
||
implementation lane wants them, they are one file read away.
|
||
|
||
---
|
||
|
||
## 9. Executive summary
|
||
|
||
1. **The wire is fully pinned, three ways, with retail as tiebreaker.** All
|
||
seven C→S opcodes (`0x00A2`–`0x00A6`, `0x0290`, `0x0291`) and seven S→C
|
||
opcodes (`0x00A3`, `0x00A4`, `0x01C9`, `0x01CA`, `0x02BE`–`0x02C0`) have
|
||
byte-exact field order in §3; the `Fellow` record is 10 × u32 + `str16L`
|
||
with the GUID always leading as key-or-field (§3.8), and the `Fellowship`
|
||
record is 8 client-read fields plus a 9th lock table the 2013 client
|
||
ignores (§3.9).
|
||
2. **acdream implements none of it** — enum values, five uncalled builders
|
||
(two malformed: `BuildFellowshipCreate` invents an `openness` field and
|
||
silently inverts `shareXP`; `BuildFellowshipUpdate` names `0x00A6`
|
||
"open" when openness is the missing `0x0291`), four correct option bits,
|
||
live fellowship *chat*, and a radar seam wired to `false` (AP-90).
|
||
3. **Two byte-decodes settled facts both decompilers got wrong**: max
|
||
fellowship size is `_currNum >= 9`, and the retail even-split share table
|
||
is `1.0 / .75 / .6 / .55 / .5 / .45 / .4 / .35 / .3111111 / .28`, default
|
||
`0.0` — a 2.8× total-XP cap from seven members up (§7).
|
||
4. **ACE diverges in four measurable places**: the 9-fellow share factor
|
||
(`.3` vs `.3111111`), the locked re-recruit window (600 s vs retail's
|
||
900 s, contradicting ACE's own "15 minutes" string), two mutually
|
||
inconsistent `shareLoot` encodings (`0x10` in full updates, `<<1` in
|
||
incremental), and hard-coded `0` for `cpCached`/`lumCached` (§4).
|
||
5. **Recommended order**: `FellowshipState` + inbound parsers → fix the two
|
||
builders and add `0x0290`/`0x0291` → the `ConfirmationType 4` branch
|
||
(cheapest user-visible slice; transport already shipped) → `0x00A6` on
|
||
panel visibility (**a hard prerequisite for live vitals — ACE gates the
|
||
`0x02C0` stream on it**) → panel → radar/hotkey consumers. Store the
|
||
wire's `_even_xp_split`; never re-derive it as authority (§6.2).
|