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

61 KiB
Raw Blame History

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 0x006A69200x006A7F1x 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 0x10000263MakeSwearConfirmationDialog @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):

  1. Server sends the generic Character.ConfirmationRequest event (0x0274) with ConfirmationType = 1 (ALLEGIANCE_SWEAR_CONFIRM, acclient.h:6976-6987).
  2. ClientUISystem::Handle_Character__ConfirmationRequest @0x005640A0 switches on the type; case 1CM_Allegiance::SendNotice_SwearAllegianceRequest(text, context) (pseudo-c:368633).
  3. gmAllegianceUI::RecvNotice_SwearAllegianceRequest @0x00493110MakeAcceptSwearConfirmationDialog, storing m_uiAcceptSwearServerContextID.
  4. 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_iidPossibleKickedVassalthe 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, 0x005B7BC00x005B8DD0); 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 roomAllegiance_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 endClientCommandRequests.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 titlenote: 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 LIVEClientCommandRequests.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 LIVETurbineChatMembershipGate.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 + dimmedCharacterOptionTable.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 + dimmedCharacterOptionTable.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 dimmedCharacterOptionTable.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-453606if ((_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 u64pseudo-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-gatedtreeParent 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 × String16Lnot 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 propertyGameMessagePrivateUpdatePropertyInt(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 outPlayer_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 validationPlayer_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-271Math.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 dimmedCharacterOptionsPageController.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 atpseudo-c:1068811/.rdata 0x00805520), which BN folded into the nearest preceding symbol. Same class at pseudo-c:158427,158429(the login/logout sentence pair insideRecvNotice_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 0x004923400x00492670 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.