docs+feat: 13 retail-AC deep-dives (R1-R13) + C# port scaffolds + roadmap E-H

78,000 words of grounded, citation-backed research across 13 major AC
subsystems, produced by 13 parallel Opus-4.7 high-effort agents. Plus
compact C# port scaffolds for the top-5 systems and a phase-E-through-H
roadmap update sequencing the work.

Research (docs/research/deepdives/):
- 00-master-synthesis.md          (navigation hub + dependency graph)
- r01-spell-system.md        5.4K words (fizzle sigmoid, 8 tabs, 0x004A wire)
- r02-combat-system.md       5.9K words (damage formula, crit, body table)
- r03-motion-animation.md    8.2K words (450+ commands, 27 hook types)
- r04-vfx-particles.md       5.8K words (13 ParticleType, PhysicsScript)
- r05-audio-sound.md         5.6K words (DirectSound 8, CPU falloff)
- r06-items-inventory.md     7.4K words (ItemType flags, EquipMask 31 slots)
- r07-character-creation.md  6.3K words (CharGen dat, 13 heritages)
- r08-network-protocol-atlas 9.7K words (63+149+94 opcodes mapped)
- r09-dungeon-portal-space.md 6.3K words (EnvCell, PlayerTeleport flow)
- r10-quest-dialogs.md       7.1K words (emote-script VM, 122 actions)
- r11-allegiance.md          5.4K words (tree + XP passup + 5 channels)
- r12-weather-daynight.md    4.5K words (deterministic client-side)
- r13-dynamic-lighting.md    4.9K words (8-light cap, hard Range cutoff)

Every claim cites a FUN_ address, ACE file path, DatReaderWriter type,
or holtburger/ACViewer reference. The master synthesis ties them into a
dependency graph and phase sequence.

Key architectural finding: of 94 GameEvents in the 0xF7B0 envelope,
ZERO are handled today — that's the largest network-protocol gap and
blocks F.2 (items) + F.5 (panels) + H.1 (chat).

C# scaffolds (src/AcDream.Core/):
- Items/ItemInstance.cs    — ItemType/EquipMask enums, ItemInstance,
                             Container, PropertyBundle, BurdenMath
- Spells/SpellModel.cs      — SpellDatEntry, SpellComponentEntry,
                             SpellCastStateMachine, ActiveBuff,
                             SpellMath (fizzle sigmoid + mana cost)
- Combat/CombatModel.cs     — CombatMode/AttackType/DamageType/BodyPart,
                             DamageEvent record, CombatMath (hit-chance
                             sigmoids, power/accuracy mods, damage formula),
                             ArmorBuild
- Audio/AudioModel.cs       — SoundId enum, SoundEntry, WaveData,
                             IAudioEngine / ISoundCache contracts,
                             AudioFalloff (inverse-square)
- Vfx/VfxModel.cs           — 13 ParticleType integrators, EmitterDesc,
                             PhysicsScript + hooks, Particle struct,
                             ParticleEmitter, IParticleSystem contract

All Core-layer data models; platform-backed engines live in AcDream.App.
Compiles clean; 470 tests still pass.

Roadmap (docs/plans/2026-04-11-roadmap.md):
- Phase E — "Feel alive": motion-hooks + audio + VFX
- Phase F — Fight + cast + gear: GameEvent dispatch, inventory,
            combat, spell, core panels
- Phase G — World systems: sky/weather, dynamic lighting, dungeons
- Phase H — Social + progression: chat, allegiance, quests, char creation
- Phase J — Long-tail (renumbered from old Phase E)

Quick-lookup table updated with 10+ new rows mapping observations to
new phase letters.
This commit is contained in:
Erik 2026-04-18 10:32:44 +02:00
parent 7230c1590f
commit 3f913f1999
20 changed files with 15312 additions and 17 deletions

View file

@ -0,0 +1,993 @@
# R11 — Allegiance System Deep Dive
**Scope:** patron / vassal tree, swearing + breaking, XP pass-up, rank,
chat channels (@a / @v / @p / @m / @c), MOTD, allegiance houses, officer
permissions, wire messages, UI panel.
**Authorities (in order of trust, per CLAUDE.md hierarchy):**
- **Retail acclient.exe decompiled**`docs/research/decompiled/chunk_00570000.c`,
`chunk_00560000.c`, `chunk_005B0000.c`, `chunk_005D0000.c`,
`chunk_00580000.c`, `chunk_006B0000.c`. Client-side strings,
channel bitmasks, error paths, UI hooks.
- **ACE server**`references/ACE/Source/ACE.Server/Entity/AllegianceNode.cs`,
`AllegianceRank.cs`, `Managers/AllegianceManager.cs`,
`WorldObjects/Allegiance.cs`, `WorldObjects/Player_Allegiance.cs`,
`Network/GameEvent/Events/Allegiance*.cs`,
`Network/Structure/AllegianceData.cs`,
`Network/Structure/AllegianceHierarchy.cs`,
`Network/Structure/AllegianceProfile.cs`,
`Network/Enum/AllegianceIndex.cs`. Full server-side implementation of
the system, plus the exact wire-format writer.
- **Asheron Wikia / Fandom wiki** — cited inline in AllegianceManager.cs
(ranking, XP passup formulas). This is the documented retail behavior
ACE codes against.
acdream is a **client**, so our primary job is:
1. Decode inbound `AllegianceUpdate` / `AllegianceInfoResponse` packets
into an in-memory allegiance tree.
2. Render the tree as a UI panel.
3. Construct and send the outbound GameActions
(`SwearAllegiance`, `BreakAllegiance`, `AllegianceInfoRequest`,
officer commands, MOTD commands, etc).
4. Handle the five allegiance-related chat channels correctly.
We do **not** compute pass-up XP — that is server-authoritative. But we
document the formula anyway so the acdream server-adjacent code (mock
server for testing, plugin API exposing it) can match retail.
---
## 1. Tree structure
### 1.1 Data model
From `AllegianceNode.cs`:
```
class AllegianceNode {
ObjectGuid PlayerGuid; // this node's player
AllegianceNode Monarch; // top of tree (self for monarch)
AllegianceNode Patron; // direct parent; null for monarch
Dictionary<uint, AllegianceNode> Vassals; // direct children
uint Rank; // 0-10
bool IsMonarch => Patron == null;
}
```
Every player in an allegiance has exactly:
- **one monarch** (root of the tree; `MonarchId` player property)
- **one patron** (direct parent; `PatronId` player property; null for
monarch)
- **0 to 11 direct vassals** (children; capped, see below)
The tree is reconstructed each time any member swears or breaks by
walking every online/offline player who reports the same `MonarchId` and
threading the `PatronId -> Vassals` links. See
`AllegianceManager.Rebuild` and `Allegiance.BuildPatronVassals`.
### 1.2 Breadth cap
**Max direct vassals: 11.** From `Player_Allegiance.cs::IsPledgable`:
```csharp
if (targetNode.TotalVassals >= 11) {
Session.Network.EnqueueSend(new GameMessageSystemChat(
$"{target.Name} already has the maximum # of vassals",
ChatMessageType.Broadcast));
return false;
}
```
This corresponds to retail error 0x416 seen in the decompiled client:
`L"%s cannot have any more Vassals"`.
### 1.3 Depth cap
**There is no hard-coded depth cap** in either the retail client strings
or ACE's enforcement. The effective cap comes from `Rank`, which is
clamped at 10 (see §4). Practically, once ranks get deep enough the
tree can keep extending downward indefinitely; the rank value just
stops climbing.
### 1.4 Loop prevention
A monarch cannot swear back into their own subtree:
```csharp
if (selfNode != null && selfNode.IsMonarch) {
if (selfNode.PlayerGuid == targetNode.Monarch.PlayerGuid) {
// refuse
}
}
```
This is the only cycle guard — and it only fires when the self side is
currently a monarch. It prevents A->B->A directly, but the same check is
implicitly satisfied elsewhere because once you are a vassal you cannot
swear at all (§2.3).
### 1.5 Counts
- `TotalVassals` = count of direct children.
- `TotalFollowers` = recursive sum (vassals of vassals…). Used for the
wire-level `totalVassals` field in the allegiance profile, and for
rank display counts.
- `TotalMembers` = from the allegiance root, full tree size.
---
## 2. Swearing and breaking
### 2.1 Swear flow (client -> server)
**Opcode:** `GameActionType.SwearAllegiance = 0x001D`.
The client sends a single 4-byte payload: the target patron's guid.
```
struct SwearAllegianceRequest {
uint32_t targetPatronGuid;
};
```
Server-side handler (`Player_Allegiance.cs::HandleActionSwearAllegiance`):
1. Look up target. Must be online.
2. Run `IsPledgable(patron)`. Checks (in order):
- Self not Olthoi, target not Olthoi.
- Target hasn't set `CharacterOption.IgnoreAllegianceRequests` (which
is "@allegiance ignore" in retail).
- Self doesn't already have a patron (`PatronId != null`).
- Target is not self.
- Target has < 11 direct vassals.
- Self is not already in target's allegiance tree (no loops).
- If allegiance is locked: either self is in `ApprovedVassals`,
or target is a Castellan+ officer.
- Self is not in target's ban list.
- Self does not own a monarch-only house (mansion). If they do:
error 0x??? via `WeenieError.CannotSwearAllegianceWhileOwningMansion`.
3. Create a `MoveToChain` toward the patron at `Allegiance_MaxSwearDistance
= 2.0f` (retail). ACE has 4.0 commented out — retail is the tighter
2.0 meters. After the move succeeds, run `SwearAllegiance(...)`.
4. `SwearAllegiance`:
- Send a `Confirmation_SwearAllegiance` to the target first. Target
must click yes.
- On confirmation: set `PatronId`, walk up to get `MonarchId`, set
both as `PropertyInstanceId.Monarch`.
- Set `ExistedBeforeAllegianceXpChanges` iff patron's level >= own
level (only equal-or-higher patrons can pass XP up in the post-2004
system — see §3.2).
- If self was previously a monarch with vassals, walk the subtree and
update every child's `MonarchId` to the new monarch. Clear
officers in the old allegiance (`HandleMonarchSwear`).
- Broadcast `Motion_Kneel` (kneeling animation).
- Rebuild the allegiance tree (`AllegianceManager.OnSwearAllegiance`).
- Reset `AllegianceXPGenerated = 0`, `AllegianceOfficerRank = null`.
- Send `GameEventAllegianceUpdate` + `GameEventAllegianceAllegianceUpdateDone`.
- Join Turbine chat channel "Allegiance" if the option is enabled.
### 2.2 Cooldown?
**There is no 24-hour cooldown on swearing itself.** Looking at both
ACE and the decompiled strings:
- ACE: `HandleActionSwearAllegiance``IsPledgable``SwearAllegiance`.
No timestamp check.
- Decompiled: error set 0x40B-0x416 covers the swear failure modes
(already sworn, not enough XP, not in allegiance, already maxed
vassals). No cooldown error.
There IS a 24-hour cooldown on **allegiance name changes** (see §7.3).
The "break once a day" player folklore is not reflected in the code;
the only cost to break is a generic 3-second animation. Players
frequently re-swore the same day, and the client/server permit it.
The wikia does mention a "1 hour cooldown" on `/allegiance house open`
after closing. Searching ACE, I don't see this enforced explicitly.
### 2.3 Break flow (client -> server)
**Opcode:** `GameActionType.BreakAllegiance = 0x001E`.
Payload is again a single uint32 guid (the patron OR vassal you're
breaking from). Handler
(`Player_Allegiance.cs::HandleActionBreakAllegiance`):
1. `IsBreakable(targetGuid)` — target exists and is either self's
patron (`PatronId == target.Guid.Full`) or one of self's vassals
(`target.PatronId == Guid.Full`). Anything else: silent refuse.
2. If breaking from vassal:
- Target's `PatronId = null`.
- Target's `MonarchId` becomes self OR null depending on whether
target has vassals themselves (new fragment root vs solo player).
- Walk target's subtree and reset every descendant's `MonarchId` to
the target.
3. If breaking from patron:
- Self's `PatronId = null`, `MonarchId = null`.
- Walk self's subtree and reset every descendant's `MonarchId` to
self (self is the new fragment monarch).
4. Messages:
- To target: `"{Name} has broken their Allegiance to you!"`
- To self: `"You have broken your Allegiance to {target.Name}!"`
5. Rebuild both allegiances.
6. Check the allegiance house — boot self and/or target if they lost
mansion access (`CheckAllegianceHouse`).
7. Send `GameEventAllegianceUpdate` + done.
### 2.4 Involuntary separation
- **Boot** (`GameActionType.BreakAllegianceBoot = 0x0277`,
Seneschal+ required): remove one member + their subtree.
Error strings include
`L"Your Allegiance has been dissolved!\n"` and
`L"Your patron\'s Allegiance to you has been broken!\n"` (errors
0x??? and 0x??? per decompiled chunk_00570000 lines 1944-1948).
- **Ban** (`AddAllegianceBan = 0x02A1`): prevents future swearing and
kicks if already a member.
- **Player delete**: `AllegianceManager.HandlePlayerDelete` collapses a
patron out of the tree; vassals each become their own new monarch.
---
## 3. XP pass-up formula
This is the most complex part of the system. From
`AllegianceManager.cs` (quoted verbatim) + wiki references:
### 3.1 Two epochs
**Pre-patch (before January 12 2004):** Used a complex formula based on
the `Self` attribute, loyalty/leadership caps, with an effective Loyalty
of 175 being enough to max patron-to-grandpatron passup. Only kill XP
passed up. **ACE does not implement this** and acdream doesn't need to.
**Post-patch (2004 onward, extended October 2009 to all XP):** Simpler
formula, decoupled from `Self`. This is what retail ran during its
final years and what ACE emulates.
### 3.2 Eligibility gate — `ExistedBeforeAllegianceXpChanges`
This flag is confusingly named — it's really "can this vassal pass XP
up?" From `Player_Allegiance.cs` line 105:
```csharp
ExistedBeforeAllegianceXpChanges = (patron.Level ?? 1) >= (Level ?? 1);
```
At swear time, if your **patron's level is less than yours**, you are
flagged as not passing XP. The flag flips on when your patron levels
above you (`AllegianceNode.OnLevelUp`). This prevents a low-level player
from being used as a "dummy patron" above a high-level farmer.
If this flag is false, **no XP passes up from that vassal at all**
(`DoPassXP` early-outs before any calculation).
### 3.3 The formula
From the comment block in `AllegianceManager.cs` (verbatim, cited to
http://asheron.wikia.com/wiki/XP_Passup, "Xerxes of Thistledown, four
months of testing"):
```
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
```
Where:
| Variable | Meaning | Cap |
|--------------|--------------------------------------------------|------|
| Loyalty | Buffed Loyalty skill on vassal | 291 |
| Leadership | Buffed Leadership skill on patron | 291 |
| RT | Real-world days vassal has been sworn to patron | 730 |
| IG | In-game hours vassal has been sworn to patron | 720 |
| RT2 | Avg real days patron's vassals sworn to patron | 730 |
| IG2 | Avg in-game hours patron's vassals sworn | 720 |
| V | Vassal-count factor: 1→0.25, 2→0.50, 3→0.75, 4+→1.00 | 1.00 |
Ranges:
- **Generated%** from 50% (new swear, zero loyalty) up to ~90% (long-term,
maxed loyalty). This is the fraction deducted from the vassal's
**earned** XP — not literally taken from them, but computed for the
passup calculation.
- **Received%** from 50% up to ~90% similarly.
- **Passup%** from 25% to 90% of the vassal's earned XP.
### 3.4 Recursion (ultrathink — this is the "fraction scales with depth")
**Grandpatron pass-up.** When vassal V earns N XP, patron P gets
`N * Passup%(V,P)`. Then P's patron (grandpatron GP) gets a share of
**what P received**, not of the original N.
But the recursion uses different constants:
```csharp
private static void DoPassXP(vassalNode, amount, bool direct)
{
...
var factor1 = direct ? 50.0f : 16.0f;
var factor2 = direct ? 22.5f : 8.0f;
...
// recursive call with direct = false
DoPassXP(patronNode, passupAmount, false);
}
```
So the grandpatron formula is:
```
Generated_indirect% = 16 + 8 * (Loyalty / 291) * (1.0 + (RT / 730) * (IG / 720))
Received_indirect% = 16 + 8 * (Leadership / 291) * (1.0 + V * (RT2 / 730) * (IG2 / 720))
Passup_indirect% = Generated_indirect% * Received_indirect% / 100
```
Caps: Generated% from 16% to 24%, Received% likewise. So
**Passup_indirect%** is bounded roughly from `0.16 * 0.16 = 2.56%` up to
`0.24 * 0.24 = 5.76%` in the "direct"-scale interpretation. Against the
wiki's stated 0%10% range, that checks out (the wiki's 10% max is
the "max leadership + max time" scenario, matching 0.24²·~1.73 = ~10%
when you account for the full time/vassal multiplier being close to 2).
This **fraction scales per generation**: a tier-3 passup is
`Direct * Indirect ≈ 0.9 * 0.1 = 9%` at maximum, or
`0.25 * 0 = 0%` at minimum. Deep trees don't feed the monarch much in
practice — the interesting XP is at direct patron level, which is why
retail players optimized around 2-3 deep at most.
The recursion terminates when `patronNode == null` (i.e., we hit the
monarch, who has no patron).
### 3.5 Earning side
When a vassal gains XP (via `GrantXP`), the amount is passed to
`AllegianceManager.PassXP(vassalNode, amount, direct=true)`:
- `generated` fraction is added to vassal's `AllegianceXPGenerated`
(tracked for player "tithed" stat).
- `passup` fraction is added to patron's `AllegianceXPCached`.
- If patron is online: they immediately redeem the cached XP via
`AddAllegianceXP()` (which grants it with `XpType.Allegiance`).
- If patron is offline: XP accumulates and is granted on next login
(with login message "Your Vassals have produced experience points
for you…").
### 3.6 ACE simplifications
ACE's implementation currently uses **capped constants** for the time
modifiers:
```csharp
var timeReal = Math.Min(RealCap, RealCap); // = RealCap
var timeGame = Math.Min(GameCap, GameCap); // = GameCap
```
i.e., it always treats every vassal as "maxed time". This is a known
simplification; to fully match retail, the server needs to track
`TimeSwornToPatron` per vassal and compute actual RT/IG values. For
our client work this doesn't matter — **we don't compute passup**, we
just read `AllegianceXPCached`, `AllegianceXPGenerated`,
`AllegianceXPReceived` from packets.
---
## 4. Rank
From `AllegianceNode.cs::CalculateRank` (cited to
http://asheron.wikia.com/wiki/Rank):
> A player's allegiance rank is a function of the number of Vassals and
> how they are organized. First, take the two highest ranked vassals.
> Now the Patron's rank will either be one higher than the lower of the
> two, or equal to the highest rank vassal, whichever is greater.
```csharp
var sortedVassals = Vassals.Values.OrderByDescending(v => v.Rank).ToList();
var r1 = sortedVassals.Count > 0 ? sortedVassals[0].Rank : 0;
var r2 = sortedVassals.Count > 1 ? sortedVassals[1].Rank : 0;
var lower = Math.Min(r1, r2);
var higher = Math.Max(r1, r2);
Rank = Math.Min(10, Math.Max(lower + 1, higher));
```
- **Starting rank:** 0 (or 1 depending on interpretation — a lone
sworn member with no vassals gets rank 0 here, and `AllegianceTitle`
returns `""` for rank 0; rank 1 kicks in when you have one vassal).
- **Max rank:** 10 (the "High King" / "Aulin" / "Tah" tier).
- Rank 9 requires a "Fibonacci-like" branching structure — two vassals
of rank 8 each, or one rank 9 + one rank 8.
### 4.1 Rank progression table
Approximate minimum-tree sizes to reach each rank:
| Rank | Tree structure | Name (Aluvian Male) |
|------|---------------------------------------------|-----------------------|
| 0 | solo / brand new (no vassals) | — |
| 1 | 1 direct vassal | Yeoman |
| 2 | 2 vassals at rank 1 | Baronet |
| 3 | 2 vassals at rank 2 | Baron |
| 4 | 2 vassals at rank 3 | Reeve |
| 5 | 2 vassals at rank 4 | Thane |
| 6 | 2 vassals at rank 5 | Ealdor |
| 7 | 2 vassals at rank 6 | Duke |
| 8 | 2 vassals at rank 7 | Aetheling |
| 9 | 2 vassals at rank 8 | King |
| 10 | 2 vassals at rank 9 | High King |
With an 11-vassal cap, each rank level roughly doubles the tree size
minimum, so rank 10 requires a ~`2^10 = 1024` member tree at minimum
(ignoring the "higher" branch).
### 4.2 Heritage-gendered titles
Each of **9 heritages × 2 genders = ~18 title tables**, each with 10
rank strings. See `AllegianceTitle.cs` lines 80-384 for the full table
(Aluvian, Gharundim, Sho, Viamontian, Shadowbound/Penumbraen,
Tumerok (gender-neutral), Gearknight (gender-neutral), Lugian
(gender-neutral), Empyrean, Undead).
acdream will copy this table **byte-for-byte** — these are the exact
strings retail displays on the allegiance panel and in tells/broadcasts.
---
## 5. Chat channels
Five logical channels, visible through distinct prefixes in the retail
client. Searched `chunk_00570000.c` for the exact prefix strings:
| Logical | Client prefix (from decompile) | ChatMessageType flag | Command |
|---------------|-------------------------------------------|----------------------|------------|
| Allegiance | `"[Allegiance Broadcast] You say, \""` | `0x2000000` | @a / broadcast |
| Co-Vassals | `"[Co-Vassals] You say, \""` | `0x1000000` | @c |
| Patron tell | `"Your patron <Tell:...> says to you, \""`| `0x1000` | @p |
| Vassal tell | `"Your vassal <Tell:...> says to you, \""`| `0x2000` | @v |
| Follower tell | `"Your follower <Tell:...> says to you, \""`| `0x4000` | @m (monarch) |
From `chunk_00570000.c` around line 700-810, the client dispatches chat
input by comparing `piVar4` (channel mask) to these hex values.
Additionally `chunk_006B0000.c::FUN_006b1460` maps the string
`"Allegiance"` to ChatMessageType **0x12** (18). That's the server-side
enum value; the `0x1000000` etc. above are per-send client-side flag
bits used to choose a prefix.
### 5.1 Channel permissions
- **@v (vassal tell)**: send to one specific direct vassal. Only works
if target is currently your vassal.
- **@p (patron tell)**: send to your patron. Only works while you have
a patron.
- **@m (monarch tell)**: send to your monarch. `@mr` replies to last
`@m` "only works for monarchs" — i.e., the monarch's receive side is
special because many vassals can @m them.
- **@c (co-vassals)**: broadcast to all peers with the same direct
patron as you. Decompiled at `chunk_00570000.c:707`.
- **@a / allegiance broadcast**: whole allegiance tree. Subject to
gag/boot filters. The decompiled strings at line 2991-3005 cover
the gag/un-gag notifications.
### 5.2 Gag / boot
- `HandleActionAllegianceChatGag` / Boot: Speaker+ permission.
- `ChatFilters: Dictionary<ObjectGuid, DateTime>` — timestamp of when
the filter expires. `DateTime.MaxValue` means permanent boot.
- `Allegiance.IsFiltered(playerGuid)` checks and auto-removes expired
filters.
- Gag duration: `Player.AllegianceChat_GagTime = TimeSpan.FromMinutes(5)`.
### 5.3 "Listen to allegiance chat" option
`CharacterOption.ListenToAllegianceChat` — if set, player joins the
turbine chat channel "Allegiance" on swear and login, leaves on break.
This is how cross-landblock allegiance chat works retail-side (Turbine
chat room backplane).
---
## 6. Allegiance house / mansion
Mansions (and some villas) can be flagged `HouseRequiresMonarch` — only
rank-N+ monarchs can purchase them, and they function as allegiance
houses where vassals gain access.
### 6.1 Purchase restrictions
From the decompiled client (`chunk_00570000.c:1516`):
- `L"You must be a monarch to purchase this dwelling.\n"` (error 0x48a)
- `L"You must be above level %s to purchase this dwelling.\n"` (0x488)
- `L"You must be at or below level %s to purchase this dwelling.\n"` (0x489)
- `L"You must be above allegiance rank %s to purchase this dwelling.\n"` (~0x476)
- `L"You must be at or below allegiance rank %s to purchase this dwelling.\n"` (~0x477)
These are the 5 gates: monarch-status, level-min, level-max, rank-min,
rank-max. Level/rank thresholds come from the SlumLord weenie.
### 6.2 Allegiance access
Action enum from `AllegianceHouseAction.cs`:
```csharp
enum AllegianceHouseAction : uint {
Undef = 0,
Help = 1,
CheckStatus = 1, // "@allegiance house help" is alias for check
GuestOpen = 2,
GuestClose = 3,
StorageOpen = 4,
StorageClose = 5,
}
```
Only Castellan+ can toggle (`AllegiancePermissionLevel.Castellan`).
### 6.3 Sanctuary / hometown recall
Monarch can set a bindstone for the whole allegiance. Stored as
`Allegiance.Sanctuary` (a `Position`). Vassals recall via
`GameActionType.RecallAllegianceHometown = 0x02AB`, which fires
`MotionCommand.AllegianceHometownRecall` animation and teleports.
### 6.4 Booting members
When a player swears away, breaks, or gets booted from allegiance, any
mansion access they had is revoked (`Player.CheckAllegianceHouse`).
---
## 7. MOTD and allegiance name
### 7.1 MOTD
Stored on the `Allegiance` worldobject as `AllegianceMotd` +
`AllegianceMotdSetBy` (string properties). On each login (after a 3s
delay) the client receives it as a system chat message:
```
"\"{motd}\" -- {setBy}"
```
- Set: `HandleActionSetMotd` (Speaker+)
- Clear: `HandleActionClearMotd` (Speaker+)
- Query: `HandleActionQueryMotd` (anyone in allegiance)
- Server sends via `GameMessageSystemChat`, not via `GameEventAllegiance*`.
Decompiled client error strings around line 2068 include
`L"Please use the allegiance panel to view your own information."`,
meaning the client recognizes an allegiance-info request against self
and redirects to the panel.
### 7.2 Allegiance name
`AllegianceName` (string property on the Allegiance object). Castellan+
only. Validation rules (per decompiled error strings 2883-2912):
- Not empty (command is `@allegiance name clear` for that).
- Max 40 chars.
- Only letters, spaces, `-`, `'`.
- Not in banned-words list from `portal.dat`.
- Not duplicate of another allegiance's name.
- Change cooldown: **once every 24 hours**
(`L"You may only change your allegiance name once every 24 hours. You may change your allegiance name again in %s.\n"`).
### 7.3 Officer titles
Rank 1/2/3 → Speaker/Seneschal/Castellan by default. Castellans can
rename them via `SetAllegianceOfficerTitle` (rank: 1-3, string title).
---
## 8. Officer permissions
From `Player_Allegiance.cs` lines 1478-1517 and `AllegiancePermissionLevel.cs`:
| Level (enum) | Numeric | Powers |
|------------------|---------|-----------------------------------------------------------------|
| None | 0 | member, no powers |
| Speaker | 1 | allegiance chat kick/gag; allegiance broadcast; set/clear MOTD |
| Seneschal | 2 | promote/demote Speakers; boot; ban; allegiance info; lock/unlock |
| Castellan | 3 | promote/demote any rank; rename titles; set allegiance name; bindstone; mansion permissions; bypass lock with approved vassals |
| Monarch | 4 | all of the above; clear all officers; everything |
`AllegianceOfficerLevel` (wire enum, uint32): `Undef=0, Speaker=1,
Seneschal=2, Castellan=3`. Monarch is implicit (Monarch doesn't have an
officer rank; they're the root).
Promote: `SetAllegianceOfficer(playerName, level)` — Seneschal+ (with
caveat Seneschal can only promote to rank 1).
Officer count limits: seen in decompile line 2738
`L"You already have the maximum number of allegiance officers. You must remove some before you add any more.\n"`.
ACE doesn't implement a cap. Retail may have had one (~12?) but it's
not enforced in ACE and no hard number appears in strings.
---
## 9. Wire messages
### 9.1 Server -> client events
From `GameEventType.cs`:
| Event | Opcode | Sent when |
|-----------------------------------|---------|--------------------------------------------------|
| `AllegianceUpdateAborted` | 0x0003 | Operation cancelled (e.g., target declined swear)|
| `AllegianceUpdate` | 0x0020 | Full tree refresh (panel open, swear, break) |
| `AllegianceAllegianceUpdateDone` | 0x01C8 | End-of-stream marker after AllegianceUpdate |
| `AllegianceLoginNotification` | 0x027A | Ally logged in/out (if listen option on) |
| `AllegianceInfoResponse` | 0x027C | Reply to AllegianceInfoRequest |
### 9.2 `AllegianceUpdate` payload
From `GameEventAllegianceUpdate.cs` + `AllegianceProfile` +
`AllegianceHierarchy` + `AllegianceData`:
```
GameEventAllegianceUpdate {
uint32_t rank; // receiving player's rank
AllegianceProfile prof; // tree
}
AllegianceProfile {
uint32_t totalMembers; // whole allegiance (monarch + all followers)
uint32_t totalVassals; // self's direct+indirect followers
AllegianceHierarchy hierarchy;
}
AllegianceHierarchy {
uint16_t recordCount; // number of ALL tree entries sent
uint16_t oldVersion; // 0x000B (latest / only supported)
PHashTable<Guid, uint32> officers; // empty in retail, present for parser compat
uint32_t officerTitleCount;
WideString[] officerTitles;
uint32_t monarchBroadcastTime;
uint32_t monarchBroadcastsToday;
uint32_t spokesBroadcastTime;
uint32_t spokesBroadcastsToday;
WideString motd; // EMPTY in ACE to avoid decal parse bugs; retail sent it
WideString motdSetBy; // EMPTY in ACE
uint32_t chatRoomID; // allegiance biota ID (chat channel)
Position bindPoint; // sanctuary/bindstone (cell + pos + rot)
WideString allegianceName;
uint32_t nameLastSetTime; // "counts upward for some reason"
uint32_t isLocked; // 0 or 1
int32_t approvedVassal; // legacy field (always 0?)
AllegianceData monarchData; // first record: the monarch
(Guid treeParent, AllegianceData)[recordCount-1] records;
}
AllegianceData {
uint32_t characterID;
uint32_t cpCached; // AllegianceXPCached clamped to uint32 max
uint32_t cpTithed; // AllegianceXPGenerated
uint32_t bitfield; // AllegianceIndex flags
uint8_t gender;
uint8_t heritage;
uint16_t rank;
// if HasPackedLevel(0x8):
uint32_t level;
uint16_t loyalty;
uint16_t leadership;
// if HasAllegianceAge(0x4):
uint32_t timeOnline;
uint32_t allegianceAge;
// else:
uint64_t uTimeOnline;
WideString name;
}
```
Per `AllegianceHierarchy.cs` comments (aclogview-derived):
- record 0 = monarch (no treeParent guid prefix, sent inline)
- record 1 = self's patron (treeParent = monarch)
- record 2 = self (treeParent = patron)
- record 3+ = self's direct vassals (treeParent = self)
This is a **compact slice** centered on the receiving player — NOT the
whole tree. To show the whole tree, the client aggregates successive
updates by walking up/down via `AllegianceInfoRequest`.
### 9.3 Bitfield (`AllegianceIndex`)
```csharp
[Flags] enum AllegianceIndex : uint {
Undefined = 0x00,
LoggedIn = 0x01,
Update = 0x02,
HasAllegianceAge = 0x04,
HasPackedLevel = 0x08,
MayPassupExperience = 0x10,
}
```
Sent with `HasAllegianceAge | HasPackedLevel` baseline; `LoggedIn`
added if player is online; `MayPassupExperience` for non-monarch
vassals with `ExistedBeforeAllegianceXpChanges` flag.
### 9.4 Client -> server actions
From `GameActionType.cs`:
| GameAction | Opcode | Payload |
|------------------------------------------|---------|--------------------------------|
| `SwearAllegiance` | 0x001D | uint32 patronGuid |
| `BreakAllegiance` | 0x001E | uint32 targetGuid |
| `AllegianceUpdateRequest` | 0x001F | uint32 uiPanelBool |
| `QueryAllegianceName` | 0x0030 | (empty) |
| `ClearAllegianceName` | 0x0031 | (empty) |
| `SetAllegianceName` | 0x0033 | string16L name |
| `SetAllegianceOfficer` | 0x003B | string16L name, uint32 level |
| `SetAllegianceOfficerTitle` | 0x003C | uint32 rank, string16L title |
| `ListAllegianceOfficerTitles` | 0x003D | (empty) |
| `ClearAllegianceOfficerTitles` | 0x003E | (empty) |
| `DoAllegianceLockAction` | 0x003F | uint32 AllegianceLockAction |
| `SetAllegianceApprovedVassal` | 0x0040 | string16L name |
| `AllegianceChatGag` | 0x0041 | string16L name, uint32 bool |
| `DoAllegianceHouseAction` | 0x0042 | uint32 AllegianceHouseAction |
| `ModifyAllegianceGuestPermission` | 0x0267 | … |
| `ModifyAllegianceStoragePermission` | 0x0268 | … |
| `BreakAllegianceBoot` | 0x0277 | string16L name, uint32 bool |
| `AllegianceInfoRequest` | 0x027B | string16L playerName |
| `AllegianceChatBoot` | 0x02A0 | string16L name, string16L reason |
| `AddAllegianceBan` | 0x02A1 | string16L name |
| `RemoveAllegianceBan` | 0x02A2 | string16L name |
| `ListAllegianceBans` | 0x02A3 | (empty) |
| `RemoveAllegianceOfficer` | 0x02A5 | string16L name |
| `ListAllegianceOfficers` | 0x02A6 | (empty) |
| `ClearAllegianceOfficers` | 0x02A7 | (empty) |
| `RecallAllegianceHometown` | 0x02AB | (empty) |
### 9.5 `ConfirmationType.SwearAllegiance`
Swearing is two-phase: target gets a `Confirmation_SwearAllegiance`
request. They must click Yes/No. The response comes back as a
`GameAction` confirmation type; ACE routes it through the
`ConfirmationManager`. acdream's client side needs to render the
confirmation dialog.
---
## 10. UI panel
The retail client's Allegiance tab (one of the main UI panels,
reachable via a bottom-bar icon) shows:
- **Header:** allegiance name + MOTD.
- **Patron row** (above center): icon + name + rank/title.
- **Self row** (center): highlighted; shows rank, level, loyalty,
leadership, CP tithed, CP received.
- **Vassal rows** (below center): each with login-status indicator
(`*` for online, per retail text "An asterisk (*) indicates that the
character is currently online"), rank, name, optional level.
- **Buttons:** Swear (disabled if already sworn), Break (only if
patron present), View (opens info request), View Monarch, Bindstone
recall (if allegiance has a sanctuary).
Clicking a member fires `AllegianceInfoRequest` → server responds
with a wider `AllegianceInfoResponse`, which the client renders as
the member's own node + their vassals (same record format).
Navigation: the client supports walking up (click patron) and down
(click vassal) to view the whole tree piecewise. The server always
sends the **3-generation slice** centered on the queried player:
patron, self, direct vassals.
### 10.1 Client commands (text input)
From decompile chunk_00570000 lines 6834-6852 (help output):
- `@help allegiances` — overview.
- `@allegiance` — allegiance commands menu.
- `@allegiance motd [text]` — set/clear MOTD (same as @motd).
- `@allegiance swear <name>` — alternative to right-click swear.
- `@allegiance break [name]` — break from patron/vassal.
- `@allegiance boot <name> [account]` — Seneschal+.
- `@allegiance chat gag <name>` / `ungag` — Speaker+.
- `@allegiance chat kick <name> <reason>` — Speaker+ (permanent).
- `@allegiance ban add/remove/list` — Seneschal+.
- `@allegiance lock on/off/toggle/check` — Seneschal+.
- `@allegiance approved add/clear/check` — Castellan+.
- `@allegiance name [new]/clear/?` — Castellan+.
- `@allegiance officer <title|rank> <level> <name>` — Seneschal+/Castellan+.
- `@allegiance officers list/clear/titles` — members see list; Seneschal+ manage.
- `@allegiance house help/guest/storage open/close` — Castellan+.
- `@allegiance hometown` — recall.
- `@allegiance ignore on/off` — per-player flag (`CharacterOption.IgnoreAllegianceRequests`).
- `@allegiance info <name>` — detailed info on specific member.
### 10.2 Chat shortcuts
- `@a <text>` — allegiance broadcast.
- `@c <text>` — co-vassals.
- `@v <name> <text>` — tell one vassal.
- `@p <text>` — tell patron.
- `@pr <text>` — reply to last @p.
- `@m <text>` — tell monarch.
- `@mr <text>` — monarch reply to last @m.
---
## 11. Port plan for acdream
### 11.1 Data types (src/acdream.core / acdream.protocol)
```csharp
public readonly record struct AllegianceGuid(uint Value);
public sealed class AllegianceNode {
public AllegianceGuid PlayerGuid { get; init; }
public string Name { get; init; } = "";
public HeritageGroup Heritage { get; init; }
public Gender Gender { get; init; }
public uint Rank { get; set; }
public uint Level { get; set; }
public ushort Loyalty { get; set; }
public ushort Leadership { get; set; }
public ulong AllegianceXPCached { get; set; }
public ulong AllegianceXPGenerated { get; set; }
public bool IsLoggedIn { get; set; }
public bool MayPassupExperience { get; set; }
public TimeSpan TimeOnline { get; set; }
public TimeSpan AllegianceAge { get; set; }
public AllegianceNode? Patron { get; set; }
public List<AllegianceNode> Vassals { get; } = new();
public bool IsMonarch => Patron is null;
// Retail rank algorithm (AllegianceNode.cs:69-88).
public void RecalculateRank() { ... }
}
public sealed class AllegianceTree {
public AllegianceNode Monarch { get; private set; }
public AllegianceNode Self { get; private set; }
public string? AllegianceName { get; set; }
public string? Motd { get; set; }
public string? MotdSetBy { get; set; }
public Position? BindPoint { get; set; }
public bool IsLocked { get; set; }
public uint ChatRoomId { get; set; }
public void ApplyUpdate(AllegianceUpdatePacket pkt) { ... }
public void ApplyInfoResponse(AllegianceInfoPacket pkt) { ... }
}
```
### 11.2 Wire codecs (src/acdream.protocol.events)
Port `AllegianceData`, `AllegianceHierarchy`, `AllegianceProfile` as
pure C# readers — **reverse** of ACE's Writer classes. Cross-reference
against `references/Chorizite.ACProtocol/Types/` for field-order
confirmation.
Event handlers to register with the dispatcher:
- `0x0003 AllegianceUpdateAborted`
- `0x0020 AllegianceUpdate`
- `0x01C8 AllegianceAllegianceUpdateDone` (just acks end of update, can
trigger UI redraw)
- `0x027A AllegianceLoginNotification` (update online status in tree)
- `0x027C AllegianceInfoResponse`
### 11.3 Outbound actions (src/acdream.protocol.actions)
One builder per GameAction listed in §9.4. Match the payload layouts
exactly. Test round-trip against captured retail pcaps if available
(none in repo currently; treat ACE's writer as ground truth in the
meantime).
### 11.4 UI panel (src/acdream.ui.allegiance)
Following the architecture doc's component pattern:
```csharp
public sealed class AllegiancePanel : IUiPanel {
private readonly AllegianceTree _tree;
private readonly IPlayerNetworkClient _net;
public void Render(ImDrawList draw) {
// Header: name + motd
// Patron row (tinted, above center)
// Self row (highlighted, center)
// Vassal list (indented, below center)
// Buttons: Swear / Break / Info / Recall
}
public void OnClickNode(AllegianceGuid guid)
=> _net.SendGameAction(new AllegianceInfoRequestAction(name));
}
```
Title strings: port the full `AllegianceTitle` table verbatim — all 18
heritage/gender variants.
### 11.5 Chat integration
Extend the chat layer (R6/UI slice 05) to handle the five channel
prefixes. Channel routing:
- Inbound: match `ChatMessageType` enum value 0x12 ("Allegiance") for
allegiance broadcasts; prefix by `[Allegiance]` in the render.
- Outbound: when user types `@a ...`, map to broadcast; `@c`, `@v
<name>`, `@p`, `@m` each go to a specific recipient guid via directed
chat (text + flag bits per §5).
- Gag/boot filter: client honors no filter state locally — server does
the enforcement — but if gagged, client should gray out the "send to
allegiance chat" button and show a status line.
### 11.6 MOTD hook
Subscribe to `GameMessageSystemChat` containing the MOTD pattern
(`"…" -- …`) and also proactively request via `QueryAllegianceName`
when opening the panel. Cache in `AllegianceTree.Motd`.
### 11.7 Testing strategy
1. **Conformance tests**: build an allegiance tree programmatically
and check rank calculation matches ACE's algorithm on edge cases
(solo, 1-chain, 11-branch wide, 10-deep narrow).
2. **Wire round-trip**: given an `AllegianceHierarchy` writer output
from a mocked ACE server, decode it with acdream's reader and
verify all fields match.
3. **Title table**: parameterized test asserting every (heritage,
gender, rank) pair matches the retail string in `AllegianceTitle.cs`.
4. **Channel routing**: feed synthetic chat-flag bytes (0x1000,
0x2000, 0x4000, 0x1000000, 0x2000000) into the parser; verify
they produce the correct prefix and channel tag.
### 11.8 Phasing
This is a **later-phase** feature — not needed for R1 (terrain render)
or R2 (basic movement). Suggested slot in the roadmap:
- **Phase A.4 or later** (after world-server login is working), when
the client first receives `AllegianceUpdate` packets.
- Initial pass: decode and log; no UI. Use logs to verify wire format.
- Second pass: render read-only tree panel.
- Third pass: outbound commands (swear/break/info) — each tested
against a mock server before pointing at a real ACEmulator instance.
- Fourth pass: chat channel integration — easy once generic chat
plumbing exists.
The plugin API should expose the entire tree, the self-node, and a
"send allegiance command" function. Automation scripts want to see
allegiance info and send MOTD updates or boot commands.
---
## 12. Key files for future work
| File | What's in it |
|---------------------------------------------------------------|-----------------------------------------------------|
| `references/ACE/.../Entity/AllegianceNode.cs` | Rank algorithm, tree walking |
| `references/ACE/.../Entity/AllegianceRank.cs` (AllegianceTitle) | Full heritage × gender × rank string table |
| `references/ACE/.../Managers/AllegianceManager.cs` | XP pass-up formulas, tree rebuild |
| `references/ACE/.../WorldObjects/Allegiance.cs` | Allegiance worldobject (name, MOTD, bans, officers) |
| `references/ACE/.../WorldObjects/Player_Allegiance.cs` | All player-side handlers (swear, break, chat, etc.) |
| `references/ACE/.../Network/Structure/Allegiance*.cs` | **Wire format writers** — reverse for acdream reader |
| `references/ACE/.../Network/GameEvent/Events/Allegiance*.cs` | Server-to-client event definitions |
| `references/ACE/.../Network/GameAction/Actions/Allegiance*.cs`| Client-to-server action definitions |
| `references/ACE/.../Entity/Enum/Allegiance*.cs` | Officer level, permission level, lock action, house action enums |
| `docs/research/decompiled/chunk_00570000.c` | Client-side chat channel bitmasks, error strings, help text |
| `docs/research/decompiled/chunk_00560000.c` | Allegiance information panel rendering (line 7245) |
| `docs/research/decompiled/chunk_006B0000.c` | ChatMessageType enum values (Allegiance = 0x12) |
---
**Next steps once we're in Phase 4+ (networking live):**
1. Capture an `AllegianceUpdate` packet from a live ACE shard.
2. Decode it with acdream's port of the hierarchy reader.
3. Spot-verify every field matches what ACE's writer would have
produced for the same node.
4. Build the tree renderer on top of that model.
5. Add outbound actions one at a time (start with `AllegianceUpdateRequest`
— it's the simplest and used most).