# Allegiance — retail mechanism, wire map, ACE status, acdream status **Campaign:** FA. **Lane:** C — allegiance mechanics + complete wire map. **Date:** 2026-08-11. **Mode:** research only — nothing built, nothing launched, no source file touched except this doc. **Sources and how to read the citations.** | Short form | Full path | |---|---| | `pseudo-c:` | `docs/research/named-retail/acclient_2013_pseudo_c.txt` | | `acclient.h:` | `docs/research/named-retail/acclient.h` | | `symbols.json` | `docs/research/named-retail/symbols.json` (PDB, 2013-09-06 EoR build) | | `ACE/…` | `C:\Users\erikn\source\repos\acdream\references\ACE\…` | | `Chorizite/…` | `C:\Users\erikn\source\repos\acdream\references\Chorizite.ACProtocol\…` | | `src/…`, `tests/…` | this worktree | > **Worktree caveat.** `references/` is NOT checked out in this worktree > (only `references/WorldBuilder/` exists here). ACE, Chorizite and > holtburger were read from the main clone at > `C:\Users\erikn\source\repos\acdream\references\`. Line numbers are from > that clone. Same for `docs/research/2026-06-04-wire-message-catalog.md`, > which exists in the main clone's working tree but not in this worktree. --- ## 0. Verdict summary | Question | Answer | |---|---| | Are the opcodes right? | **Every one verified from the retail binary itself**, not inferred. The task prompt's parentheticals were all correct: swear `0x001D`, break `0x001E`, info request `0x027B`, `AllegianceUpdate` `0x0020`, `AllegianceInfoResponse` `0x027C`. §3 gives the address of the literal store for each. | | How many allegiance messages are there? | **27 C→S GameActions + 5 S→C GameEvents.** All 27 senders live in one class, `CM_Allegiance` (`symbols.json`, 27 `Event_*` entries), and every one is a fixed-shape flat body — no PackObj, no flags word, on the C→S side. | | Is the tree a flat list or a real tree? | **A real tree, assembled client-side from a flat record vector.** Each record after the first carries a `treeParent` guid; `AllegianceHierarchy::Add @0x005B6E90` inserts as left-child/right-sibling. **A record whose `treeParent` is not already in the tree makes `AllegianceHierarchy::UnPack` return 0 and the client drops the ENTIRE message** (§4.4). Parent-before-child ordering is a hard wire contract, not a nicety. | | Is the hierarchy blob version-gated? | **Yes, eleven separate gates on a `u16 oldVersion` field** (`acclient.h:2979-2994` + `pseudo-c:454484`). ACE always writes `0x000B` (newest), so against ACE the gates are inert — but a parser that hardcodes the 0x0B layout is only correct because ACE is generous. §4.2 has the full gate table. | | Does the client enforce the level rules for swearing? | **No.** `gmAllegianceUI::UpdateSwearButton @0x004908E0` gates only on "no patron / have a selection / selection is a player / selection is not already in my allegiance". Level, distance, vassal count, lock, ban are **all server-side**. | | Does ACE implement the whole feature? | Swear / break / boot / passup / motd / name / officers / titles / bans / lock / approved-vassal are **all implemented server-side**. But the `AllegianceUpdate` hierarchy blob **deliberately zeroes officers, officer titles, motd, motdSetBy, nameLastSetTime, isLocked and approvedVassal**, and never fills `timeOnline`/`allegianceAge` (§5.2). Everything except the tree itself reaches the player as chat text. | | What does acdream have today? | **One live path and one dead scaffold.** Live: `@allegiance info` — request `0x027B` + a full `AllegianceProfile` parser + retail-verbatim text lines. Dead: `AllegianceRequests` (swear/break builders) and `AllegianceTree`/`AllegianceMath`, referenced only by their own unit tests. **Zero handlers for `0x0020` / `0x01C8` / `0x027A` / `0x0003`.** No allegiance panel. §6. | | Biggest single trap for the implementer | The **already-shipped `AllegianceProfile` parser in `ClientCommandResponses.cs` is the one to reuse** — do not write a second one for the panel. It is byte-correct against ACE today; it needs the version gates added and its skipped fields surfaced, not a rewrite. §7.2. | Two defects found in passing, both in acdream, both currently harmless because the code is unreachable: 1. **`AllegianceMath.ComputePassup` is a mis-transcription of the retail formula.** `src/AcDream.Core/Allegiance/AllegianceTree.cs:177` computes `(50.0 + 22.5 * loyaltyLevel) / 291.0`. The real formula (ACE `Managers/AllegianceManager.cs:242`, quoting retail) is `50.0 + 22.5 * (loyalty / 291)`, **as a percentage** — i.e. `× 0.01` at the end. acdream's version returns ~1000× the correct number for a maxed vassal and has the loyalty term inside the wrong parenthesis. The unit test at `tests/AcDream.Core.Tests/Allegiance/AllegianceTreeTests.cs:94` locks in the wrong value. **Recommendation: delete the class** (§7.3) — the client never needs this number; the server sends `_cp_tithed`. 2. **`AllegianceTree` models a patron→vassal edge the wire does not carry.** Its `UpsertNode(guid, name, patronGuid, rank)` assumes the server names each node's patron. The wire names each record's **tree parent**, which for ACE's own writer is *not* always the real patron (§5.3 — ACE hangs a non-monarch patron directly off the monarch). Also delete; the parsed record list plus `treeParent` is the tree. --- ## 1. Retail client mechanism ### 1.1 The three actors | Class | Address | Role | |---|---|---| | `ClientAllegianceSystem` | ctor `0x0056A150` (`pseudo-c:375113`) | Owns the ONE cached `CAllegianceProfile` for the session (`acclient.h:40628-40632`). Receives all four S→C allegiance events and re-broadcasts them as local notices. Cleared on `OnEndCharacterSession @0x00569FA0`. | | `gmAllegianceUI` | ctor `0x00490B40`; struct `acclient.h:56038-56061` | The panel. Subscribes/unsubscribes, renders monarch/patron/self/vassals, owns four dialog contexts, sends swear/break/kick. | | `CM_Allegiance` | `0x006A6920`–`0x006A7F1x` | The message layer. 27 `Event_*` senders (C→S), 4 `SendNotice_*` (system→UI), 3 `DispatchUI_*` (wire→system). | The data path is strictly one-way through the system object: ``` wire → UIQueueManager dispatch switch (pseudo-c:359190+) → CM_Allegiance::DispatchUI_* (unpack) → ClientAllegianceSystem::Handle_Allegiance__* (cache into m_allegianceProfile) → CM_Allegiance::SendNotice_* (local fan-out) → gmAllegianceUI::RecvNotice_* (repaint) ``` `ClientAllegianceSystem::Handle_Allegiance__AllegianceUpdate @0x0056A120` (`pseudo-c:375103`) does exactly two things: `AllegianceProfile::operator=` into the cached profile, then `SendNotice_AllegianceUpdate`. **The panel never reads the wire; it reads the cache.** Any acdream port should keep that split — a Runtime-owned `AllegianceProfile` snapshot, a presentation layer that reads it. ### 1.2 Panel lifecycle — when `UpdateRequest` fires `CM_Allegiance::Event_UpdateRequest(u32)` (opcode `0x001F`) is a **subscribe/unsubscribe toggle**, not a one-shot query: | Site | Address | Arg | |---|---|---| | `gmAllegianceUI::PostInit` tail block | `0x004911C6` (`pseudo-c:157414`) | `1` | | `gmAllegianceUI::RecvNotice_PlayerDescReceived` | `0x00490D59` (`pseudo-c:157175`) | `1` | | `gmAllegianceUI::OnVisibilityChanged`, visible branch | `0x004912DD` (`pseudo-c:157495`) | `1` | | `gmAllegianceUI::OnVisibilityChanged`, hidden branch | `0x00491311` (`pseudo-c:157455`) | `0` | `RecvNotice_PlayerDescReceived @0x00490D40` also calls `ClientUISystem::IncrementBusyCount` and sets a latch; `Update @0x00492670` and the hidden branch both `DecrementBusyCount` when `m_bAwaitingUpdate` is set. **Retail shows the busy cursor from "I asked" until "the update landed".** That is the whole reason `AllegianceUpdateDone (0x01C8)` and `AllegianceUpdateAborted (0x0003)` exist as separate terminators. ### 1.3 Swear — the complete two-sided flow **Initiator (would-be vassal):** 1. Select a player. `gmAllegianceUI::UpdateSwearButton @0x004908E0` (`pseudo-c:156945`) enables the button (`SetState(1)`) **only** when all of: `AllegianceProfile::GetPatron(self) == 0` (I have no patron), `ACCWeenieObject::selectedID != 0`, `selectedID != player_id`, `GetWeenieObject(selectedID)->IsPlayer()`, and `AllegianceProfile::GetData(selectedID) == 0` (they are not already in my allegiance). Otherwise `SetState(0xD)` — retail's disabled state. **There is no level, distance, or vassal-count check in the client.** 2. Click → `ListenToElementMessage @0x00493030` sees element `0x10000263` → `MakeSwearConfirmationDialog @0x004927B0`. This is a **local** dialog built via `DialogFactory::MakeDialogInCurrentUI`, storing its handle in `m_swearContext`. Text is a StringInfo with the target's `GetObjectName(NAME_APPROPRIATE)` bound as variable 0. 3. On OK → `CM_Allegiance::Event_SwearAllegiance(m_iidPossibleNewPatron)` (`pseudo-c:158356`; see the BN artifact note in §8). **Target (would-be patron):** 4. Server sends the **generic** `Character.ConfirmationRequest` event (`0x0274`) with `ConfirmationType = 1` (`ALLEGIANCE_SWEAR_CONFIRM`, `acclient.h:6976-6987`). 5. `ClientUISystem::Handle_Character__ConfirmationRequest @0x005640A0` switches on the type; `case 1` → `CM_Allegiance::SendNotice_SwearAllegianceRequest(text, context)` (`pseudo-c:368633`). 6. `gmAllegianceUI::RecvNotice_SwearAllegianceRequest @0x00493110` → `MakeAcceptSwearConfirmationDialog`, storing `m_uiAcceptSwearServerContextID`. 7. On close → `gmAllegianceUI::CloseAcceptSwearConfirmationDialog @0x00490A50` sends `CM_Character::Event_ConfirmationResponse(1, contextId, accepted)` — GameAction **`0x0275`**, 0x18-byte body (`pseudo-c:679924`). **There is no allegiance-specific accept message.** The accept rides the shared confirmation pipeline, which acdream already implements end to end (§6.4). ### 1.4 Break and kick — same opcode, different target Both are `Event_BreakAllegiance(u32)` (`0x001E`): | Path | Handler | Target guid | |---|---|---| | Break button `0x10000264` → confirm | `CloseBreakConfirmationDialog @0x00490A90` (`pseudo-c:157046`) | `AllegianceProfile::GetPatron(self)` — **my patron** | | Kick button `0x10000265` → confirm | `CloseKickConfirmationDialog @0x00490B00` (`pseudo-c:157073`) | `m_iidPossibleKickedVassal` — **the selected vassal** | `gmAllegianceUI::UpdateBreakButton @0x004909D0` enables Break iff `GetPatron(self) != 0`. The Kick button is enabled from the vassal list-box selection handler (`ListenToElementMessage`, `idMessage == 4`, reading attribute `0x10000001` off the selected row). ACE mirrors this exactly: `Player.IsBreakable` accepts either patron or vassal (`ACE/Source/ACE.Server/WorldObjects/Player_Allegiance.cs:406-428`). ### 1.5 Panel population — the tree walk `gmAllegianceUI::UpdateVassalsData @0x00492340` (`pseudo-c:158470`): `UIElement_ListBox::Flush`, clear `m_iidSelectedVassal`, then ``` id = AllegianceProfile::GetFirstVassal(profile, player_id, &data) while (id != 0) { row = AddItemFromTemplateList(...) SetAttribute_InstanceID(row, 0x10000001, id) GetChildRecursive(row, 0x10000268) -> SetText(AllegianceData::GetFullName(data)) GetChildRecursive(row, 0x10000269) -> SetText(...) // second column id = AllegianceProfile::GetNextVassal(profile, id, &data) } ``` **There is no paging.** The list box holds every direct vassal (retail caps that at 11, enforced server-side). The tree accessors are trivial pointer walks over the assembled node graph: | Accessor | Address | Implementation | |---|---|---| | `GetMonarch` | `0x005B6A80` | `LookUp(m_pMonarch->_data._id)` | | `GetPatron(id)` | `0x005B6DD0` | `Search(id)->_patron->_data` | | `GetFirstVassal(id)` | `0x005B6E10` | `Search(id)->_vassal->_data` (first child) | | `GetNextVassal(id)` | `0x005B6E50` | `Search(id)->_peer->_data` (next sibling) | | `GetData(id)` | `0x005B6AE0` | `LookUp(id)` | `AllegianceData::GetFullName @0x005B6950` = `" <name>"` when `AllegianceSystem::GetTitle(rank, heritage, gender)` succeeds, else the bare name. `AllegianceSystem` has 20 per-heritage/gender title tables (`symbols.json`, `AllegianceSystem::Get*Title`, `0x005B7BC0`–`0x005B8DD0`); ACE has an independent port at `ACE/Source/ACE.Server/Entity/AllegianceRank.cs`. `AllegianceData::IsLoggedIn @0x005B6550` = `_bitfield & 1`. ### 1.6 Logon/logoff notices — the `ShowAllegianceLogons` semantics `gmAllegianceUI::RecvNotice_AllegianceLogin @0x00492220` (`pseudo-c:158415`) does: 1. `AllegianceProfile::GetData(cachedProfile, guid, &data)` — **if the guid is not in the cached profile, return silently, no text.** So a member outside your own monarch/patron/self/vassal slice produces nothing even if the server sends the event. 2. Build `GetFullName(data)` + one of two literal strings selected by the bool arg (BN renders the two string pointers as bogus vftable members — see §8). 3. `ClientSystem::AddTextToScroll(text, 0, 1, 0)`. **Critical semantics finding: the client does NOT consult `DisplayAllegianceLogonNotifications` here.** A full-file grep of the pseudo-C for that accessor (`pseudo-c:171333,171339,171342,366545,366548, 486601,486609,487502,487792,487794`) finds **only** the Options-page checkbox row, the chargen default sweep, and the `PlayerModule` get/set/ switch cases. There is no consumer. The bit is **purely a server-side filter**: retail's server decides whether to send `0x027A` at all. ACE implements exactly that — `Player_Allegiance.cs:454` (login) / `:466` (logout) iterate `Allegiance.OnlinePlayers` and send only to members whose `CharacterOption.ShowAllegianceLogons` is set. Same finding for `IgnoreAllegianceRequests` (`pseudo-c:171301,171307, 171310,366303,366306,485960,485968,487387,487677,487679`): Options row + chargen + `PlayerModule` plumbing, **no client consumer**. ACE consumes it at `Player_Allegiance.cs:316-322`, refusing the swear and sending `WeenieError.YourOfferOfAllegianceWasIgnored` (`0x048E`) to the initiator. This **vindicates acdream's AD-78 classification of both rows as store-only/dimmed** (§6.5) — retail's own client does not read them either. `HearAllegianceChat` is the opposite: it IS read client-side, as the `hearOption` argument to `ClientCommunicationSystem::SendTurbineChat(..., Allegiance_ChatTypeEnum, text, PlayerModule::HearAllegianceChat(...))` (`pseudo-c:394269`, `396405`), and it is set by the `/allegiance chat on|off` verbs (`pseudo-c:380739` sets 1, `:380867` sets 0, `ClientCommunicationSystem::DoAllegianceChat @0x00575CB0` sets it from the parsed argument at `pseudo-c:386302`). ### 1.7 Allegiance chat Two distinct channels, both already understood by acdream's chat work: * **Turbine allegiance room** — `Allegiance_ChatTypeEnum = 0x1` (`acclient.h:4467`). Room id comes from `ChatRoomTracker::GetAllegianceRoomID @0x004F0E90` and from the hierarchy blob's `m_chatRoomID`. `AllegianceChatBlob : TurbineChatBlob` (`acclient.h:59921`) adds one field, `m_monarchID`. Sent via `ClientCommunicationSystem::DoTurbineChat_Allegiance @0x0057EBA0`. * **Legacy `AllegianceBroadcast` channel** — bit `0x02000000`, verb `@a`, via `DoAllegianceBroadcast @0x005761F0`. acdream's `ChannelResolver.cs:44` already maps this, and `ChatChannelInfo.cs:78` already marks it self-echo. acdream's `TurbineChatMembershipGate.cs:74-76,107-110` already gates the allegiance room join on `AllegianceRoom != 0` **and** the `HearAllegianceChat` bit — which is the retail rule. **No allegiance chat work is needed for the panel campaign.** ### 1.8 The `/allegiance` verb tree in the 2013 client `ClientCommunicationSystem::DoAllegiance @0x0057D5A0` (`pseudo-c:393086`) is a linear string-compare chain. Verbs recovered from the literal comparisons in address order: `boot`, `info`, `chat`, (alias `data_7e0cb8`), `broadcast`, (alias `data_7e0ca8`), (`data_7e0ca4`), `officer`, `title`, `hometown`, … plus the sibling entry points `DoAllegianceBan @0x005762A0`, `DoAllegianceLock @0x00576E70`, `DoAllegianceName @0x00576C80`, `DoAllegianceOfficerTitle @0x00576A10`, `DoAllegianceHouse @0x0056EF70`, `DoAllegianceHometown @0x0056EF10`. Help text: `HelpAllegiance @0x0057AE10`. **So yes — the 2013 client exposes allegiance name, motd, officer and officer-title management, but only as chat verbs, never as panel widgets.** `gmAllegianceUI` has no name field, no motd field, no officer list; its only text elements are `m_pAllegianceName`, `m_pPlayerFollowers`, `m_pPlayerRank`, `m_pMonarchName`, `m_pMonarchFollowers`, `m_pPatronName` (`acclient.h:56043-56055`). --- ## 2. Master table Direction is C→S unless noted. "Retail anchor" is the address of the opcode literal store inside the `CM_Allegiance` sender, which is the strongest possible provenance — the retail client writing its own opcode. | # | Feature | Retail anchor | C→S wire | S→C wire | ACE status | acdream status | Recommendation | |---|---|---|---|---|---|---|---| | 1 | **Swear allegiance** | `Event_SwearAllegiance` opcode store `0x006A71EA` (`pseudo-c:686090`) | `0x001D` + `u32 targetGuid` (16 B) | `0x0274` confirm to patron → `0x0020`+`0x01C8` to both | **Full.** `GameActionAllegianceSwearAllegiance.cs:9-11`; `Player_Allegiance.cs:66-137`; distance gate 2.0 m, `IsPledgable` at `:296-401` | Builder exists, **unwired** (`AllegianceRequests.BuildSwear`, `src/AcDream.Core.Net/Messages/AllegianceRequests.cs:34`) | **Implement** | | 2 | **Break (from patron)** | `0x006A69DA` (`pseudo-c:685669`) | `0x001E` + `u32 patronGuid` (16 B) | `0x0020`+`0x01C8` | **Full.** `GameActionAllegianceBreakAllegiance.cs:9-11`; `Player_Allegiance.cs:170-261` | Builder exists, unwired | **Implement** | | 3 | **Break (kick vassal)** | same as #2 | `0x001E` + `u32 vassalGuid` | `0x0020`+`0x01C8` | **Full.** `IsBreakable` accepts vassal (`Player_Allegiance.cs:418-428`) | none | **Implement** | | 4 | **Panel subscribe/refresh** | `0x006A72BA` (`pseudo-c:686147`) | `0x001F` + `u32 on` (16 B) | `0x0020` then `0x01C8` | **Partial.** `GameActionAllegianceUpdateRequest.cs:12` *reads* `uiPanel` then **ignores it** — always replies once, never treats it as a subscription | none | **Implement send; do not depend on subscription** | | 5 | **Tree/profile push** | `pseudo-c:359250-359259` (dispatch case `0x20`) | — | **S→C `0x0020`**: `u32 rank` + `AllegianceProfile` | **Full but lossy.** `GameEventAllegianceUpdate.cs:20-25`; also pushed on tree change (`Allegiance.cs:361`, `AllegianceManager.cs:398`) | **no handler** | **Implement — reuse §7.2 parser** | | 6 | **Update terminator** | `CM_Allegiance` has no sender; consumed by `gmAllegianceUI::Update` busy-count | — | **S→C `0x01C8`**: `u32 WeenieError` | **Full.** `GameEventAllegianceAllegianceUpdateDone.cs:10` | enum only (`GameEventType.cs:55`) | **Implement (clears busy)** | | 7 | **Update aborted** | `DispatchUI_AllegianceUpdateAborted @0x006A6950`, tests `*(u32*)arg2 == 3` (`pseudo-c:685651`) | — | **S→C `0x0003`**: `u32 WeenieError` | **Declared, never sent.** Only `GameEventType.cs:5`; no `GameEvent*` class | enum only (`GameEventType.cs:16`) | **Store** (parse + log; ACE will never send it) | | 8 | **Logon/logoff notice** | `DispatchUI_AllegianceLoginNotificationEvent @0x006A6920`, tests `== 0x27a` (`pseudo-c:685636`) | — | **S→C `0x027A`**: `u32 charGuid` + `u32 bool isLoggedIn` | **Full.** `GameEventAllegianceLoginNotification.cs:12-13`; gated on recipient's `ShowAllegianceLogons` (`Player_Allegiance.cs:454,466`) | **no handler** | **Implement** (2 chat lines) | | 9 | **`@allegiance info`** | `0x006A7829` (`pseudo-c:686446`) | `0x027B` + `String16L name` | **S→C `0x027C`**: `u32 targetGuid` + `AllegianceProfile` | **Full.** `GameActionAllegianceInfoRequest.cs:13-15`; `GameEventAllegianceInfoResponse.cs:10-11` | **LIVE end to end** — `ClientCommandRequests.cs:262`, `ClientCommandResponses.cs:241-379`, wired at `GameEventWiring.cs:192-197` | **Done — reuse** | | 10 | **Boot from allegiance** | `0x006A78E9` (`pseudo-c:686474`) | `0x0277` + `String16L name` + `u32 accountBoot` | `0x0020`+`0x01C8` | **Full.** `GameActionBreakAllegianceBoot.cs:11-14` | none | **Store** (chat verb `@allegiance boot`) | | 11 | **Query allegiance name** | `0x006A7036` (`pseudo-c:686018`) | `0x0030`, no payload (12 B) | system chat text | **Full.** `Player_Allegiance.cs:578-596` | none | **Store** | | 12 | **Clear allegiance name** | `0x006A6AA6` | `0x0031`, no payload | system chat text | **Full.** `Player_Allegiance.cs:630-651`; requires Castellan | none | **Store** | | 13 | **Set allegiance name** | `0x006A7C29` (`pseudo-c:686618`) | `0x0033` + `String16L name` | system chat text | **Full but unvalidated.** `Player_Allegiance.cs:598-628` — its own TODO lists 7 missing name checks | none | **Store** | | 14 | **Set officer** | `0x006A7CE9` (`pseudo-c:686646`) | `0x003B` + `String16L name` + `u32 AllegianceOfficerLevel` | system chat text | **Full.** `Player_Allegiance.cs:767-828` | none | **Store** | | 15 | **Set officer title** | `0x006A7DED` (`pseudo-c:686705`) | `0x003C` + `u32 rank` + `String16L title` — **note: u32 BEFORE the string** | system chat text | **Full.** `Player_Allegiance.cs:699-738` | none | **Store** | | 16 | **List officer titles** | `0x006A6F16` | `0x003D`, no payload | system chat text | **Full.** `Player_Allegiance.cs:676-697` | none | **Store** | | 17 | **Clear officer titles** | `0x006A6B36` | `0x003E`, no payload | system chat text | **Full.** `Player_Allegiance.cs:740-765` | none | **Store** | | 18 | **Lock action** | `0x006A6DBA` (`pseudo-c:685885`) | `0x003F` + `u32 AllegianceLockAction` | system chat text | **Full.** `GameActionDoAllegianceLockAction.cs` | none | **Store** | | 19 | **Set approved vassal** | `0x006A7B69` (`pseudo-c:686590`) | `0x0040` + `String16L name` | system chat text | **Full.** `GameActionSetAllegianceApprovedVassal.cs` | none | **Store** | | 20 | **Chat gag** | `0x006A7729` (`pseudo-c:686388`) | `0x0041` + `String16L name` + `u32 on` | system chat text | **Full.** `GameActionAllegianceChatGag.cs` | none | **Store** | | 21 | **House action** | `0x006A6CEA` (`pseudo-c:685828`) | `0x0042` + `u32 AllegianceHouseAction` | house messages | **Full.** `GameActionDoAllegianceHouseAction.cs` | none | **Store** (house scope) | | 22 | **Set MOTD** | `0x006A7ED9` (`pseudo-c:686763`) | `0x0254` + `String16L motd` | system chat text | **Full.** `Player_Allegiance.cs:524-549`; requires Speaker | none | **Store** | | 23 | **Query MOTD** | `0x006A70C6` | `0x0255`, no payload | system chat text | **Full.** `Player_Allegiance.cs:502-522`; also auto-sent 3 s after login (`:436-439`) | none | **Store** | | 24 | **Clear MOTD** | `0x006A6C56` | `0x0256`, no payload | system chat text | **Full.** `Player_Allegiance.cs:551-576` | none | **Store** | | 25 | **Chat boot** | `0x006A765E` (`pseudo-c:686360`) | `0x02A0` + `String16L name` + `String16L reason` | system chat text | **Full.** `GameActionAllegianceChatBoot.cs` | none | **Store** | | 26 | **Add ban** | `0x006A7589` (`pseudo-c:686332`) | `0x02A1` + `String16L name` | system chat text | **Full.** `GameActionAddAllegianceBan.cs`; `Allegiance.cs:383-395` | none | **Store** | | 27 | **Remove ban** | `0x006A79E9` | `0x02A2` + `String16L name` | system chat text | **Full.** `GameActionRemoveAllegianceBan.cs` | none | **Store** | | 28 | **List bans** | `0x006A6E86` | `0x02A3`, no payload | system chat text | **Full.** `GameActionListAllegianceBans.cs` | none | **Store** | | 29 | **Remove officer** | `0x006A7AA9` | `0x02A5` + `String16L name` | system chat text | **Full.** `Player_Allegiance.cs:830-882` | none | **Store** | | 30 | **List officers** | `0x006A6FA6` | `0x02A6`, no payload | system chat text | **Full.** `Player_Allegiance.cs:653-674` | none | **Store** | | 31 | **Clear officers** | `0x006A6BC6` | `0x02A7`, no payload | system chat text | **Full.** `Player_Allegiance.cs:884-...` | none | **Store** | | 32 | **Recall hometown** | `0x006A7156` | `0x02AB`, no payload | teleport | **Full.** `GameActionRecallAllegianceHometown.cs` | **LIVE** — `ClientCommandRequests.cs:257`, `ClientCommandController.cs:335-336` | **Done** | | 33 | **XP passup** | — (no client message) | — | reaches client as `_cp_tithed`/`_cp_cached` in `AllegianceData`, plus a login chat line | **Full.** `AllegianceManager.cs:240-320` | none needed | **Neither — server-only** | | 34 | **Allegiance chat** | `DoTurbineChat_Allegiance @0x0057EBA0` | `0xF7DE` TurbineChat, `ChatType = 1` | `0xF7DE` | **Full** | **LIVE** — `TurbineChatMembershipGate.cs:74-110` | **Done** | | 35 | **`IgnoreAllegianceRequests` bit** | `PlayerModule::IgnoreAllegianceRequests @0x005D2AE0` | `0x0005` (auto-save) / `0x01A1` blob | in `0x0013` PlayerDescription options word | **Consumed.** `Player_Allegiance.cs:316-322` | **stored + dimmed** — `CharacterOptionTable.cs:112` (Options1 `0x00000004`, autoSave), `CharacterOptionsPageController.cs:195` | **Correct as-is** | | 36 | **`ShowAllegianceLogons` bit** | `PlayerModule::DisplayAllegianceLogonNotifications @0x005D3200` | same | same | **Consumed.** `Player_Allegiance.cs:454,466` | **stored + dimmed** — `CharacterOptionTable.cs:135` (Options1 `0x08000000`, NOT autoSave) | **Correct as-is** | | 37 | **`HearAllegianceChat` bit** | `PlayerModule::HearAllegianceChat @0x005D3560` | same | same | **Consumed** at `Player_Allegiance.cs:138-139` (auto-joins Turbine room on swear) | **LIVE, not dimmed** — `CharacterOptionTable.cs:138` (Options1 `0x40000000`, autoSave) | **Correct as-is** | **Recommendation legend.** *Implement* = the FA panel cannot work without it. *Store* = parse/build it and keep the state, but no panel widget in retail either — it is a chat verb; ship it with the chat-command registry, not the panel. *Done* = already live in acdream. --- ## 3. C→S field order (golden-byte-vector sources) ### 3.1 The envelope every `CM_Allegiance` sender writes All 27 senders share one shape, visible in every body: ``` OrderHdr::Pack(&hdr, &cursor, totalSize) // 8 bytes: u32 0xF7B1, u32 gameActionSequence *(u32*)cursor = <subOpcode>; // 4 bytes <payload> Proto_UI::SendToWeenie(buffer, totalSize) ``` The arithmetic proves the header size: a no-payload action allocates `0xC` = 12 bytes (`Event_QueryAllegianceName`, `pseudo-c:686017`), a one-u32 action allocates `0x10` = 16, a `String16L`-only action allocates `strPackSize + 0xC`, a `String16L + u32` action allocates `strPackSize + 0x10`. So **header 8 + opcode 4**, exactly matching acdream's `AllegianceRequests.Build` (`AllegianceRequests.cs:48-56`) and `ClientCommandRequests.BuildParameterless/BuildString`. After a `u32` field the senders emit the standard PackObj DWORD-alignment pad loop (`*(u8*)cursor = 0` until 4-aligned). For these fixed layouts the cursor is always already aligned, so **no padding bytes are ever emitted** in practice. A `String16L` is `u16 length` + CP-1252 bytes + pad to 4. ### 3.2 Per-message layouts ``` SwearAllegiance 0x001D u32 targetGuid total 0x10 BreakAllegiance 0x001E u32 targetGuid total 0x10 AllegianceUpdateRequest 0x001F u32 on(0|1) total 0x10 QueryAllegianceName 0x0030 — total 0x0C ClearAllegianceName 0x0031 — total 0x0C SetAllegianceName 0x0033 String16L name str+0x0C SetAllegianceOfficer 0x003B String16L name, u32 officerLevel str+0x10 SetAllegianceOfficerTitle 0x003C u32 rank, String16L title str+0x10 ← u32 FIRST ListAllegianceOfficerTitles 0x003D — total 0x0C ClearAllegianceOfficerTitles 0x003E — total 0x0C DoAllegianceLockAction 0x003F u32 AllegianceLockAction total 0x10 SetAllegianceApprovedVassal 0x0040 String16L name str+0x0C AllegianceChatGag 0x0041 String16L name, u32 on str+0x10 DoAllegianceHouseAction 0x0042 u32 AllegianceHouseAction total 0x10 SetMotd 0x0254 String16L motd str+0x0C QueryMotd 0x0255 — total 0x0C ClearMotd 0x0256 — total 0x0C BreakAllegianceBoot 0x0277 String16L name, u32 accountBoot str+0x10 AllegianceInfoRequest 0x027B String16L name str+0x0C AllegianceChatBoot 0x02A0 String16L name, String16L reason str1+str2+0x0C AddAllegianceBan 0x02A1 String16L name str+0x0C RemoveAllegianceBan 0x02A2 String16L name str+0x0C ListAllegianceBans 0x02A3 — total 0x0C RemoveAllegianceOfficer 0x02A5 String16L name str+0x0C ListAllegianceOfficers 0x02A6 — total 0x0C ClearAllegianceOfficers 0x02A7 — total 0x0C RecallAllegianceHometown 0x02AB — total 0x0C ``` `SetAllegianceOfficerTitle`'s inverted order is the only field-order surprise in the set, and it is unambiguous in the decomp (`pseudo-c:686705-686710`: opcode store, then `*(u32*)eax_5 = arg1`, then the alignment pad, then `PStringBase::Pack(arg2, ...)`). **Three-way agreement.** Every layout above matches ACE's reader (`ACE/Source/ACE.Server/Network/GameAction/Actions/GameAction*.cs`) and Chorizite's generated `Messages/C2S/Actions/Allegiance_*.generated.cs` field-for-field. The only encoding question is `bool` width: retail writes a full `u32`, ACE reads `Convert.ToBoolean(ReadUInt32())`, and Chorizite's `ReadBool` is `ReadInt32()` under the hood (`Chorizite/Chorizite.ACProtocol/Extensions/BinaryReaderExtensions.cs:178-181`). **All three agree on 4 bytes.** (Note this contradicts the "wire encoding may differ" caution at `docs/research/2026-06-04-wire-message-catalog.md:3138` for `0x0041` — that concern is resolved: 4 bytes.) ### 3.3 The shared confirmation response (swear accept) ``` ConfirmationResponse 0x0275 u32 confirmType, u32 context, u32 accepted total 0x18 ``` `CM_Character::Event_ConfirmationResponse @0x006A1210` (`pseudo-c:679924-679935`). acdream already builds this: `ClientCommandRequests.ConfirmationResponseOpcode = 0x0275u` (`ClientCommandRequests.cs:22`). --- ## 4. S→C field order — `AllegianceProfile` and the tree This is the panel's core data structure; the rest of the feature is plumbing around it. ### 4.1 The three records ``` AllegianceProfile (acclient.h:36297-36303; UnPack @0x005B6B90, pseudo-c:453802) u32 totalMembers // _total_members u32 totalVassals // _total_vassals AllegianceHierarchy AllegianceHierarchy (acclient.h:36275-36295; UnPack @0x005B7520, pseudo-c:454484) u16 recordCount // low half of one u32 read u16 oldVersion // high half of the same u32 [version-gated body — see 4.2] AllegianceData monarchRecord // only if recordCount > 0, NO treeParent { u32 treeParent; AllegianceData } × (recordCount - 1) AllegianceData (acclient.h:35815-35831; UnPack @0x005B6850, pseudo-c:453566) u32 _id // character guid u32 _cp_cached // XP accrued while patron was offline u32 _cp_tithed // lifetime XP contributed upward u32 _bitfield // AllegianceIndex, acclient.h:7714-7722 u8 _gender u8 _hg // heritage group u16 _rank if (_bitfield & 0x08 /*HasPackedLevel*/) u32 _level u16 _loyalty u16 _leadership if (_bitfield & 0x04 /*HasAllegianceAge*/) { u32 _time_online; u32 _allegiance_age; } else { f64 timeOnlineDouble → _ftol2 → _time_online; _allegiance_age = 0; } String16L _name ``` `AllegianceData::UnPack` refuses payloads shorter than `0x20` bytes (`pseudo-c:453569`). `AllegianceIndex` values are identical in retail (`acclient.h:7714-7722`) and ACE (`ACE/Source/ACE.Server/Network/Enum/AllegianceIndex.cs`): `LoggedIn 0x1`, `Update 0x2`, `HasAllegianceAge 0x4`, `HasPackedLevel 0x8`, `MayPassupExperience 0x10`. **Two retail behaviours no other reference documents:** 1. **When `HasPackedLevel` is absent, the client sets `MayPassupExperience` itself**: `pseudo-c:453601-453606` — `if ((_bitfield & 8) == 0) this->_bitfield = (_bitfield | 0x10);`. Legacy-packet compatibility. Harmless against ACE (which always sets `HasPackedLevel`), but a client port that also sets the bit is *more* faithful than one that does not. 2. **The non-`HasAllegianceAge` branch reads an 8-byte `double`, not a `u64`** — `pseudo-c:453634-453637` reads `*(uint64_t*)` then converts with `_ftol2` (the MSVC float→integer helper). Chorizite models this as `ReadUInt64` (`Types/AllegianceData.generated.cs:90`) and ACE writes `ulong 0` (`Network/Structure/AllegianceData.cs:115`). Same 8 bytes, so nothing breaks — but the field is a **double**, and ACE's own comment calls the branch "probably deprecated". Flagged, not actionable. Also note `AllegianceHierarchy::UnPack`'s last act: `AllegianceData::SetMayPassupExperience(&monarch->_data, 0)` (`pseudo-c:454601-454603`) — **the monarch can never pass up.** ### 4.2 The eleven version gates `AllegianceVersion` (`acclient.h:2979-2994`) and the exact predicate each field is behind, straight from `AllegianceHierarchy::UnPack`: | Field | Gate | Decomp line | |---|---|---| | `m_AllegianceOfficers` (`PHashTable<u32,u32>`) | `oldVersion >= 6` (`MultipleAllegianceOfficersAdded`) | `pseudo-c:454507` | | **4-byte skip** (the old single `spokesperson` id) | `1 <= oldVersion < 6` **and** ≥4 bytes remain | `pseudo-c:454502-454506` | | `m_OfficerTitles` (`PSmartArray<PString>`) | `oldVersion >= 9` (`OfficersTitlesAdded`) | `pseudo-c:454522` | | `m_monarchBroadcastTime` | `oldVersion >= 2` (`PoolsAdded`) **and** ≥4 bytes | `pseudo-c:454531` | | `m_monarchBroadcastsToday` | same block, ≥4 bytes | `pseudo-c:454541` | | `m_spokesBroadcastTime` | same block, ≥4 bytes | `pseudo-c:454550` | | `m_spokesBroadcastsToday` | same block, ≥4 bytes | `pseudo-c:454559` | | `m_motd`, `m_motdSetBy` | `oldVersion >= 3` (`MotdAdded`) | `pseudo-c:454570,454579` | | `m_chatRoomID` | `oldVersion >= 4` (`ChatRoomIDAdded`) | `pseudo-c:454588` | | `m_BindPoint` (`Position`) | `oldVersion >= 7` (`Bindstones`) | `pseudo-c:454596` | | `m_AllegianceName` + `m_NameLastSetTime` | `oldVersion >= 8` (`AllegianceName`) | `pseudo-c:454604,454606` | | `m_isLocked` | `oldVersion >= 10` (`LockedState`) | `pseudo-c:454612` | | `m_ApprovedVassal` | `oldVersion >= 11` (`ApprovedVassal`) | `pseudo-c:454616` | `BannedCharactersAdded_AllegianceVersion = 5` gates **nothing** in `UnPack` — the ban list never rides the hierarchy blob. (`@allegiance ban list` is opcode `0x02A3` + chat text.) **Nothing in the record loop is version-gated** — `treeParent` is always present for records 1..N-1 regardless of version. ### 4.3 Sub-record encodings * `PHashTable<u32,u32>` header is `u16 count` + `u16 numBuckets`, then `count × (u32 key, u32 value)`. ACE writes header `numBuckets = 256` with a comment that the client actually uses 23 (`Network/Structure/AllegianceHierarchy.cs:164-183`); acdream's parser already reads it that way (`ClientCommandResponses.cs:256-262`). * `PSmartArray<PString>` / officer titles is a **bare `int32` count** then N × `String16L` — **not** the u16/u16 hash header. ACE writes it that way (`AllegianceHierarchy.cs:185-190`); acdream reads it that way (`ClientCommandResponses.cs:266-268`). * `Position` is `u32 cell` + `3 × f32 pos` + `4 × f32 rotation (W,X,Y,Z)` = 32 bytes (`AllegianceHierarchy.cs:192-212`). * `String16L` = `u16 byteLength` + CP-1252 bytes + pad to 4. ### 4.4 Tree assembly — the rule that will bite `AllegianceHierarchy::Add(treeParentId, data) @0x005B6E90` (`pseudo-c:454063`): ``` if (data._id == 0) return 0; // reject if (m_pMonarch == nullptr) { // FIRST record wins the root m_pMonarch = new AllegianceNode(data); // treeParentId is IGNORED here m_total++; return 1; } parent = (treeParentId != 0) ? Search(treeParentId, m_pMonarch) : nullptr; if (parent == nullptr) return 0; // ← unknown parent: FAIL if (parent->_data._id == data._id) return 0; // ← self-parent: FAIL if (Search(data._id, m_pMonarch) != 0) return 0; // ← duplicate id: FAIL node = new AllegianceNode(data); node->_patron = parent; node->_peer = parent->_vassal; // push front of sibling list parent->_vassal = node; m_total++; return 1; ``` and the caller (`pseudo-c:454564-454590`) turns **any** `Add` failure into `return 0` from `UnPack`, which the dispatcher treats as a dead message. Three hard consequences for any server or test fixture: 1. **Parents must precede children in the record vector.** ACE satisfies this by construction (monarch, then patron, then self, then vassals). 2. **No duplicate guids.** A record repeated anywhere kills the message. 3. **Sibling order is reversed on read** — the last record added under a parent becomes `_vassal` (first in the `GetFirstVassal`/`GetNextVassal` walk). The vassal list-box therefore renders in **reverse wire order**. Worth an eyeball check against retail before calling a port faithful. ### 4.5 The four S→C events ``` 0x0020 AllegianceUpdate u32 rank; AllegianceProfile 0x01C8 AllegianceUpdateDone u32 WeenieError 0x0003 AllegianceUpdateAborted u32 WeenieError 0x027A AllegianceLoginNotification u32 characterGuid; u32 isLoggedIn 0x027C AllegianceInfoResponse u32 targetGuid; AllegianceProfile ``` Sources, all three independent and in agreement: * retail: dispatch case `0x20` at `pseudo-c:359250-359259` (reads a u32, advances 4, then `AllegianceProfile::UnPack`); `DispatchUI_*` opcode tests at `pseudo-c:685636` (`0x27a`), `:685651` (`3`), `:686309` (`0x27c`); `Handle_Allegiance__AllegianceLoginNotificationEvent` reads `arg2+4` and `arg2+8`. * ACE: `GameEventAllegianceUpdate.cs:22-25`, `GameEventAllegianceAllegianceUpdateDone.cs:10`, `GameEventAllegianceInfoResponse.cs:10-11`, `GameEventAllegianceLoginNotification.cs:12-13`. * Chorizite: `Messages/S2C/Events/Allegiance_*.generated.cs`. `0x0020` is the ONLY message where the profile is preceded by a `rank` word; `0x027C` precedes it with a **guid**. A shared parser must take the leading word as a parameter, not bake it in. --- ## 5. ACE caveats — read this before scoping ACE's allegiance *logic* is complete. Its *presentation blob* is not. ### 5.1 Fields ACE never populates in `AllegianceUpdate`/`InfoResponse` `ACE/Source/ACE.Server/Network/Structure/AllegianceHierarchy.cs`: | Field | Value ACE actually sends | Line | Note | |---|---|---|---| | `officers` | **always empty** | `:74-75` (the `foreach` is commented out), `:175` ("always sent as empty in retail?") | ACE *has* the data (`Allegiance.Officers`) — it just doesn't ship it | | `officerTitles` | **always empty** | `:78-83` — commented out with *"not in retail packets, breaks decal"* | ACE has `AllegianceSpeakerTitle`/`Seneschal`/`Castellan` | | `motd`, `motdSetBy` | **forced to `""`** | `:86-89` — *"fixes decal AllegianceUpdate parsing"* | The real motd goes out as `GameMessageSystemChat` instead | | `nameLastSetTime` | `0` | `:62,153` | never assigned | | `isLocked` | **`false` hardcoded** | `:63,154` | ACE *does* track `Allegiance.IsLocked` and enforces it (`Player_Allegiance.cs:372`) — it just never tells the client | | `approvedVassal` | `0` | `:64,155` | same story | | `monarch/spokes BroadcastTime/Today` | `0` | `:53-56` | never assigned | | `chatRoomID` | `allegiance.Biota.Id` | `:90` | real | | `bindPoint` | `allegiance.Sanctuary` or default | `:92-93` | real | | `allegianceName` | real, falling back to the monarch's name | `:85` | real | `ACE/Source/ACE.Server/Network/Structure/AllegianceData.cs`: | Field | Value | Line | |---|---|---| | `timeOnline`, `allegianceAge` | **always `0`** — explicit TODO | `:59-60,86-89,111-112` | | everything else | real | `:69-91` | **Scoping consequence:** a faithful acdream panel that displays MOTD, officers, lock state or allegiance age **will show blanks against ACE**, not because the client is wrong. Either (a) ship those as chat-verb output only, matching what ACE actually serves, or (b) file ACE-side patches. Do not chase a "bug" in the parser. ### 5.2 Behaviour ACE implements that the wire map might not suggest * **Swear needs proximity and consent.** `HandleActionSwearAllegiance` runs `CreateMoveToChain(patron, …, Allegiance_MaxSwearDistance = 2.0f)` then a `Confirmation_SwearAllegiance` round-trip (`Player_Allegiance.cs:75`, `:291`). The client's own swear button has no distance check — the walk is the server's. * **`AllegianceUpdate` is pushed unsolicited** on tree changes to every online member (`Allegiance.cs:361` inside `UpdateProperties`, `AllegianceManager.cs:398` on removal) — **regardless of whether the panel subscribed.** So a client that never sends `0x001F` will still get updates on change; it just won't get one on demand. * **`AllegianceRank` also arrives as a property** — `GameMessagePrivateUpdatePropertyInt(PropertyInt.AllegianceRank)` (`Allegiance.cs:352`), and `PropertyInstanceId.Monarch` via `UpdateProperty` (`:341`). acdream already models both (`PropertyInt.cs:85`, `PropertyInstanceId.cs:56`) — a second, cheaper source of truth for the rank display. * **MOTD is auto-pushed 3 s after login** as broadcast chat (`Player_Allegiance.cs:433-441`), together with the cached-XP line *"Your Vassals have produced experience points for you…"*. ### 5.3 Where ACE's tree shape diverges from retail's `AllegianceHierarchy.cs:103-136` builds exactly four kinds of record: monarch, patron (only if the patron is not the monarch), self, and direct vassals. Two divergences: 1. **The patron record is hung off the monarch**, not off the patron's real patron: `records.Add(new Tuple<>(node.Monarch.PlayerGuid, new AllegianceData(node.Patron)))` at `:124`. In a deep allegiance the tree the client assembles is therefore *flattened* — grandpatrons and above simply are not sent. 2. **`recordCount` counts self + patron + monarch + direct vassals only** (`:103-111`), so the panel never sees co-vassals or a wider slice. The comment block at `:95-101` admits this is reverse-engineered from aclogview and unverified. `AllegianceProfile.cs:32-33` sends `totalMembers = monarch.TotalFollowers + 1` and `totalVassals = node.TotalFollowers` — note `TotalFollowers` is the recursive count, so **`totalVassals` is not the same as the number of records in the vassal list** (which is direct vassals only). Retail's panel shows `m_pPlayerFollowers` from the profile counter and the list from the tree walk — they legitimately disagree. ### 5.4 Rules ACE does NOT enforce * **The patron-level rule is commented out** — `Player_Allegiance.cs:341-351`, the `target.Level < Level` check plus its `WeenieError.AllegianceIllegalLevel` are inside `/* … */`. So on ACE you can swear to a lower-level patron. Retail's server enforced it (the error code exists, `0x0410`; acdream already models it at `src/AcDream.Core/Physics/WeenieError.cs:304`). * **Allegiance name validation** — `Player_Allegiance.cs:615-622` lists 7 unimplemented checks (empty, length ≤ 40, uniqueness, bad chars, portal.dat banned words, 1-day timer). The error codes exist in acdream already (`WeenieError.cs:530-532`). * **`AllegianceUpdateAborted (0x0003)` is never sent** — the enum member exists (`GameEvent/GameEventType.cs:5`) with no message class. ### 5.5 XP passup — cite, do not re-derive `ACE/Source/ACE.Server/Managers/AllegianceManager.cs:240-320`, quoting retail in its own header comment: ``` Generated % = 50.0 + 22.5 * (loyalty/291) * (1.0 + (RT/730) * (IG/720)) Received % = 50.0 + 22.5 * (leadership/291) * (1.0 + V * (RT2/730) * (IG2/720)) Passup % = Generated% * Received% / 100.0 ``` with `V` = `min(0.25 × directVassals, 1.0)` and, for **indirect** (recursive) passup, the constants drop to `16.0`/`8.0` (`:275-276`). ACE currently pins all four time terms to their caps (`:268-271` — `Math.Min(Cap, Cap)`), so time-sworn has no effect there today. **The client needs none of this.** `_cp_tithed` and `_cp_cached` arrive pre-computed in `AllegianceData`. This is the argument for deleting acdream's `AllegianceMath` rather than fixing it. --- ## 6. acdream today ### 6.1 Live: `@allegiance info` (feature #9) The only fully wired allegiance path. * Request: `ClientCommandRequests.BuildAllegianceInfoRequest` (`src/AcDream.Core.Net/Messages/ClientCommandRequests.cs:262-263`), opcode constant at `:56`. * Command: `ClientCommandId.AllegianceInfo` (`src/AcDream.UI.Abstractions/ClientCommandId.cs:82`), dispatched at `src/AcDream.App/UI/ClientCommandController.cs:339-340`. * Parse: `ClientCommandResponses.ParseAllegianceInfoResponse` (`src/AcDream.Core.Net/Messages/ClientCommandResponses.cs:241-307`) — a **complete `AllegianceProfile` reader**, including the officers hash header, the bare-int32 officer-title count, the 32-byte Position, and the `HasPackedLevel`/`HasAllegianceAge` branches (`ReadAllegianceData`, `:309-336`). * Tree accessors ported from retail: `FindData` / `FindPatron` / `FindVassals` (`:197-238`). * Render: `FormatAllegianceInfoLines` (`:357-379`) — a verbatim port of `Handle_Allegiance__AllegianceInfoResponseEvent @0x0056A1D0`, including the `" *"` online marker and the retail behaviour of printing **nothing** when the queried player has no record. * Wiring: `src/AcDream.Core.Net/GameEventWiring.cs:192-197`. **Known limitation:** it does not read `oldVersion` (`:250` explicitly discards it) and assumes the 0x0B layout. Correct against ACE forever; wrong against any hypothetical older server. Adding the §4.2 gates is a contained change to one function. ### 6.2 Live: recall hometown (feature #32) and allegiance chat (#34) `ClientCommandRequests.cs:257`, `ClientCommandController.cs:335-336`; `TurbineChatMembershipGate.cs:74-76,107-110`, `ChannelResolver.cs:44`, `ChatChannelInfo.cs:78`, `TurbineChatDisplayNames.cs:37` (allegiance display slot `0x12`). ### 6.3 Dead scaffolds — recommend deletion | File | Status | Evidence | |---|---|---| | `src/AcDream.Core.Net/Messages/AllegianceRequests.cs` (57 lines) | Byte-correct swear/break builders. **Zero production callers** — only `tests/AcDream.Core.Net.Tests/Messages/AllegianceRequestsTests.cs` | repo-wide grep for `AllegianceRequests` outside its own file returns only that test | | `src/AcDream.Core/Allegiance/AllegianceTree.cs` (183 lines) | `AllegianceNode` + `AllegianceTree` + `AllegianceMath`. **Zero production callers** — only `tests/AcDream.Core.Tests/Allegiance/AllegianceTreeTests.cs` | same grep | `AllegianceRequests` is worth **keeping and wiring** (its layout is right). `AllegianceTree`/`AllegianceMath` should be **deleted** — see §0 defects 1 and 2 and §7.3. ### 6.4 Confirmation pipeline — already sufficient for swear-accept `src/AcDream.App/UI/GameplayConfirmationController.cs` handles `0x0274` request → dialog → `0x0275` response → `0x0276` done generically. Its own comment at `:36-39` explicitly says *"Types 1 and 4 have allegiance/ fellowship semantic owners but use the same response tuple, so this controller retains that tuple until those panels exist."* **One presentational gap.** ACE sends the confirmation `text` = the vassal's bare `Name` (`Player_Allegiance.cs:91`, `ConfirmationManager.cs:38`). Retail's type-1 handler binds that name into a StringInfo template (`MakeSwearConfirmationDialog @0x004927B0` shows the pattern: `StringInfo::SetStringIDandTableEnum` + `AddVariable_String`) producing a real sentence. acdream's generic path (`:51-53`, which only appends `" Continue?"` for types 2/3/5/6) would show the raw name. **The FA panel should take ownership of type 1 and supply the sentence**, exactly as retail's `gmAllegianceUI` does. ### 6.5 Character options — the three allegiance bits | Option | id | Word / mask | Auto-save | Client default | acdream row | |---|---|---|---|---|---| | `IgnoreAllegianceRequests` | `0x01` | Options1 `0x00000004` | yes | off | **dimmed** (`CharacterOptionsPageController.cs:195`) | | `DisplayAllegianceLogonNotifications` | `0x18` | Options1 `0x08000000` | no | off | **dimmed** (`:197`) | | `ListenToAllegianceChat` | `0x1B` | Options1 `0x40000000` | yes | **on** | **not dimmed** — `CharacterOptionsPageController.cs:229`, marked `Live`, comment cites `TurbineChatMembershipGate.cs:107-110` | Source of truth: `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs:112`, `:135`, `:138`. Ids match retail's `PlayerOption` enum (`acclient.h:4166,4189,4192`); masks match retail's `CharacterOption` (`acclient.h:3408,3429,3432`). Tests that pin the dimming: `tests/AcDream.App.Tests/UI/Layout/ CharacterOptionsPageControllerTests.cs:226` and `:728` (the "Grouping" group's six ids, of which two are the allegiance pair) and `:359` (seeds `IgnoreAllegianceRequests` ON to prove `Bind` reads current values). Headless declares them by name at `src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs:30` and `tests/AcDream.Headless.Tests/HeadlessCharacterOptionsSeederWiringTests.cs:72,84,126,255`. **§1.6 confirms this classification is retail-faithful, not a shortcut: neither bit has a client-side consumer in the retail binary either.** ### 6.6 Absent entirely No `AllegianceUiController` / `AllegiancePanelController` — the layout directory (`src/AcDream.App/UI/Layout/`) has 60 controllers and none is allegiance. No handler for `0x0020`, `0x01C8`, `0x027A`, or `0x0003` (repo-wide grep for those `GameEventType` members returns one doc-comment reference in the dead `AllegianceTree.cs:28`). No `ConfirmationType` enum. No swear/break/kick UI. ### 6.7 Adjacent data acdream already models `PropertyInstanceId.Allegiance = 24` (`PropertyInstanceId.cs:56`); `PropertyInt.AllegianceCpPool = 29`, `AllegianceRank = 30`, `AllegianceFollowers = 35`, `ItemAllegianceRankLimit = 110`, `NumAllegianceBreaks = 132`, `AllegianceMinLevel = 163`, `AllegianceMaxLevel = 164`, `AllegianceSwearTimestamp = 198` (`PropertyInt.cs`); `PropertyString.AllegianceName = 47`; `RadarBlipShape.AllegianceMember` (`RetailRadar.cs:29,113-114`) driven by `IsAllegianceMember` (`RadarBlipColors.cs:62`) — **currently always `false`, because nothing populates allegiance membership.** Wiring #5 would light the radar blips for free. `ItemAppraisalTextFormatter.cs:861` already renders the "Allegiance Rank" requirement row. 33 allegiance `WeenieError` values are already enumerated (`src/AcDream.Core/Physics/WeenieError.cs:299-308,389,395-396,402-405, 435-437,447,463,506-512,530-532`) — everything `0x01C8`/`0x0003` can carry. --- ## 7. Implement-vs-store recommendations ### 7.1 Minimum viable retail allegiance panel Six messages, in dependency order: 1. **`0x0020` `AllegianceUpdate`** — parse `u32 rank` + profile into a Runtime-owned snapshot. Everything else depends on this. 2. **`0x01C8` `AllegianceUpdateDone`** — clears the busy latch; carries the `WeenieError` for a failed swear/break. 3. **`0x001F` `AllegianceUpdateRequest`** — send `1` on panel show and on PlayerDescription, `0` on hide (§1.2). ACE ignores the value but retail servers do not; send it correctly. 4. **`0x001D` `SwearAllegiance`** — builder already exists. 5. **`0x001E` `BreakAllegiance`** — builder already exists; serves both Break and Kick with a different target guid. 6. **`0x027A` `AllegianceLoginNotification`** — two chat lines, gated on "is the guid in my cached profile" (§1.6), **not** on a local option bit. Plus: take ownership of `ConfirmationType == 1` in `GameplayConfirmationController` (§6.4). Everything else in §2 is **store**: build the GameAction, route the reply into the chat log, do not build a widget. Retail did not build widgets for them either. ### 7.2 Reuse, do not rewrite, the profile parser `ClientCommandResponses.ParseAllegianceInfoResponse` is already a correct `AllegianceProfile` reader. Refactor it into a shared `AllegianceProfileReader` taking the profile span (both callers prepend a different leading u32 — rank vs guid, §4.5), then: * add the §4.2 version gates, * surface the currently-discarded fields the panel needs: `rank`, `level`, `loyalty`, `leadership`, `cpCached`, `cpTithed`, `gender`, `heritage`, `chatRoomID`, `bindPoint`, * keep `FindData`/`FindPatron`/`FindVassals` — they are already ports of the retail accessors, * keep the retail assembly rules from §4.4 as the parse contract (reject unknown parent, reject duplicate id) so a malformed server blob fails the same way retail fails it. The one behaviour to add on top: **sibling order reverses on assembly** (§4.4) — either build the real linked structure or reverse the per-parent list when rendering. ### 7.3 Delete * `src/AcDream.Core/Allegiance/AllegianceTree.cs` and `tests/AcDream.Core.Tests/Allegiance/AllegianceTreeTests.cs` — the tree model contradicts the wire (§0 defect 2) and the passup math is wrong and unnecessary (§0 defect 1, §5.5). Deleting is strictly safer than leaving a wrong formula with a passing test next to a new correct implementation. ### 7.4 Divergence-register rows this campaign will owe * ACE never populates officers / officer titles / motd / lock / approvedVassal / nameLastSetTime / broadcast counters / timeOnline / allegianceAge in the profile blob (§5.1) — the panel renders blanks. **Adaptation**, symptom: "MOTD and officer fields are always empty". * ACE flattens the patron chain onto the monarch and sends only monarch/patron/self/direct-vassals (§5.3) — **approximation**, symptom: "the tree above my patron is missing". * ACE does not enforce the patron-level rule (§5.4) — **server-side divergence**, symptom: "I can swear to a level-1 patron". * `totalVassals` (recursive) versus the direct-vassal list length (§5.3) — **explained divergence**, symptom: "the follower count doesn't match the list". --- ## 8. BN decomp artifacts observed (zero-fold / symbol-fold warnings) Per the standing BN caution, these are decompiler artifacts in the cited lines — **not** evidence of the semantics the names suggest. None changes any conclusion above; all are flagged so a later reader does not "fix" a non-bug. 1. **String constants rendered as vftable members.** `pseudo-c:171301` shows `compute_str_hash(&gmAllegianceUI::`vftable'.RecvNotice_AddSpellShortcut)` — that operand is the address of the literal `"ID_PlayerOption_IgnoreAllegianceRequests"` (the real string is at `pseudo-c:1068811` / `.rdata 0x00805520`), which BN folded into the nearest preceding symbol. Same class at `pseudo-c:158427,158429` (the login/logout sentence pair inside `RecvNotice_AllegianceLogin`) and `:453655` (`data_794098`, the separator inside `GetFullName`). 2. **Field access folded into an unrelated member path.** `pseudo-c:158356` renders the swear send as `Event_SwearAllegiance(this->m_hashElementsRegisteredWith.m_intrusiveTable.m_aInplaceBuckets[1])`. The real field is `gmAllegianceUI::m_iidPossibleNewPatron` (`acclient.h:56042`), reached by raw offset; BN attributed the offset to the wrong base. Confirmed by the symmetric kick path (`pseudo-c:157079`) which BN *does* render correctly as `this->m_iidPossibleKickedVassal`. 3. **`this` adjusted by a constant on multiple-inheritance thunks.** `gmAllegianceUI::RecvNotice_AllegianceUpdate @0x00492780` reads `gmAllegianceUI::Update(((char*)this - 0x5F8))` — the `-0x5F8` is the `gmNoticeHandler` sub-object adjustment, not a real pointer offset. Same at `pseudo-c:159260` (`RecvNotice_SwearAllegianceRequest`) and `:158415`. 4. **Dead comparison idioms.** `(ebx_1 - ebx_1) == 0xff` throughout `DoAllegiance` (`pseudo-c:393086+`) is BN's rendering of a sign-extended byte compare against `PStringBase::cmp`'s result; read it as "strings matched". 5. **No literal-zero opcode folds were found.** Every one of the 27 `CM_Allegiance::Event_*` senders shows an explicit non-zero opcode store (§3.2). There is no place in this feature where a `0` operand might be hiding an id. 6. **`AllegianceHierarchy::UnPack` renders `arg2` as an alias for the remaining-size variable** in places (`pseudo-c:454518,454530,…`) — BN reusing the parameter slot as a local. The size arithmetic is still readable; the *field order* (the part that matters) is unambiguous. --- ## 9. Unknowns for the coordinator 1. **The Allegiance panel's LayoutDesc id is not known.** Retail element ids ARE known from the decomp — swear `0x10000263`, break `0x10000264`, kick `0x10000265`, vassal-row name `0x10000268`, vassal-row second column `0x10000269`, row instance-id attribute `0x10000001` (`pseudo-c:159207-159240`, `:158466-158510`) — but the containing `0x21000xxx` LayoutDesc is not derivable from the decomp and is not in `docs/research/retail-ui/`. **Resolve with a UI-Studio `--dump` sweep** the same way CH6a found the chat window's real id (`0x2100006F`, after an earlier wrong guess of `0x21000006` — see `src/AcDream.App/UI/Layout/ChatWindowController.cs:30`). Do not guess. 2. **What is the vassal row's second text column (`0x10000269`)?** The `UpdateVassalsData` body past `pseudo-c:158500` was not fully read. Candidates: rank title, level, or `_cp_tithed`. One more decomp pass on `0x00492340`–`0x00492670` settles it. 3. **Sibling render order.** §4.4 shows the assembly reverses wire order. Whether retail's list box therefore shows newest-vassal-first is a **visual gate question** — worth one side-by-side check against retail before the panel is called done. 4. **Does ACE's unsolicited `AllegianceUpdate` on tree change actually reach a client that never subscribed?** §5.2 says yes by code reading (`Allegiance.cs:361` is unconditional). Cheap to confirm on the live ACE with two accounts once #5 is parsed. 5. **`AllegianceHierarchy::GetMinPackSize @0x0041BC20` was not read** — it sets `AllegianceProfile::UnPack`'s minimum-size guard (`minPackSize + 8`, `pseudo-c:453805`). Only matters for a truncation-rejection conformance test. 6. **Officer/motd/name management scope.** All 20-odd management opcodes are chat verbs in retail, and ACE serves them. Are they in FA's scope, or does FA stop at the panel? The §2 "store" column assumes panel-only; if the chat verbs are in scope, they are a mechanical addition to the existing 152-verb registry with no new parsing work (every reply is plain system chat). 7. **`Confirmation_SwearAllegiance` text ownership.** §6.4 — should the FA panel own `ConfirmationType == 1` and compose the sentence locally (retail-faithful), or should we ask for an ACE-side text change? The retail-faithful answer is local composition; flagging because it means touching `GameplayConfirmationController`, which Campaign CH owns. --- ## 10. Executive summary 1. **Every opcode in the prompt was correct and is now verified from the retail binary's own opcode stores**, not inferred: swear `0x001D`, break `0x001E`, subscribe `0x001F`, info request `0x027B`, `AllegianceUpdate` `0x0020`, `UpdateDone` `0x01C8`, `Aborted` `0x0003`, `LoginNotification` `0x027A`, `InfoResponse` `0x027C` — 27 C→S actions and 5 S→C events in total, all field orders three-way agreed across retail, ACE and Chorizite. 2. **The panel's core data structure is a flat record vector the client folds into a left-child/right-sibling tree**, with eleven version gates on a `u16 oldVersion` and one brutal rule: a record whose `treeParent` is not already in the tree makes the client discard the entire message. 3. **acdream already ships a correct `AllegianceProfile` parser** (the `@allegiance info` path) — reuse it with version gates and more fields surfaced; it needs no rewrite. There are zero handlers for the other four allegiance events and no panel. 4. **ACE implements all the allegiance logic but deliberately zeroes officers, officer titles, MOTD, lock state, approved vassal and allegiance age in the profile blob**, and flattens the patron chain onto the monarch — so a faithful panel will legitimately show blanks, and the plan must scope those as chat-text features, not panel widgets. 5. **The two dimmed allegiance option rows are retail-faithful, not a shortcut** — the retail client has no consumer for `IgnoreAllegianceRequests` or `DisplayAllegianceLogonNotifications` either; both are pure server-side filters. Meanwhile `src/AcDream.Core/Allegiance/AllegianceTree.cs` is a dead scaffold whose passup formula is transcribed wrong and whose tree model contradicts the wire — delete it rather than build on it.