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>
1015 lines
61 KiB
Markdown
1015 lines
61 KiB
Markdown
# 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.
|