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

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

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

1015 lines
61 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

# 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:<line>` | `docs/research/named-retail/acclient_2013_pseudo_c.txt` |
| `acclient.h:<line>` | `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` = `"<title> <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.