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.
41 KiB
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:
- Decode inbound
AllegianceUpdate/AllegianceInfoResponsepackets into an in-memory allegiance tree. - Render the tree as a UI panel.
- Construct and send the outbound GameActions
(
SwearAllegiance,BreakAllegiance,AllegianceInfoRequest, officer commands, MOTD commands, etc). - 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;
MonarchIdplayer property) - one patron (direct parent;
PatronIdplayer 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:
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:
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-leveltotalVassalsfield 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):
- Look up target. Must be online.
- 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.
- Create a
MoveToChaintoward the patron atAllegiance_MaxSwearDistance = 2.0f(retail). ACE has 4.0 commented out — retail is the tighter 2.0 meters. After the move succeeds, runSwearAllegiance(...). SwearAllegiance:- Send a
Confirmation_SwearAllegianceto the target first. Target must click yes. - On confirmation: set
PatronId, walk up to getMonarchId, set both asPropertyInstanceId.Monarch. - Set
ExistedBeforeAllegianceXpChangesiff 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
MonarchIdto 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.
- Send a
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):
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.- If breaking from vassal:
- Target's
PatronId = null. - Target's
MonarchIdbecomes 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
MonarchIdto the target.
- Target's
- If breaking from patron:
- Self's
PatronId = null,MonarchId = null. - Walk self's subtree and reset every descendant's
MonarchIdto self (self is the new fragment monarch).
- Self's
- Messages:
- To target:
"{Name} has broken their Allegiance to you!" - To self:
"You have broken your Allegiance to {target.Name}!"
- To target:
- Rebuild both allegiances.
- Check the allegiance house — boot self and/or target if they lost
mansion access (
CheckAllegianceHouse). - Send
GameEventAllegianceUpdate+ done.
2.4 Involuntary separation
- Boot (
GameActionType.BreakAllegianceBoot = 0x0277, Seneschal+ required): remove one member + their subtree. Error strings includeL"Your Allegiance has been dissolved!\n"andL"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.HandlePlayerDeletecollapses 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:
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:
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):
generatedfraction is added to vassal'sAllegianceXPGenerated(tracked for player "tithed" stat).passupfraction is added to patron'sAllegianceXPCached.- If patron is online: they immediately redeem the cached XP via
AddAllegianceXP()(which grants it withXpType.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:
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.
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
AllegianceTitlereturns""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.
@mrreplies 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.MaxValuemeans 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:
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 viaGameEventAllegiance*.
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 clearfor 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)
[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)
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 AllegianceUpdateAborted0x0020 AllegianceUpdate0x01C8 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:
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
ChatMessageTypeenum value 0x12 ("Allegiance") for allegiance broadcasts; prefix by[Allegiance]in the render. - Outbound: when user types
@a ..., map to broadcast;@c,@v <name>,@p,@meach 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
- 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).
- Wire round-trip: given an
AllegianceHierarchywriter output from a mocked ACE server, decode it with acdream's reader and verify all fields match. - Title table: parameterized test asserting every (heritage,
gender, rank) pair matches the retail string in
AllegianceTitle.cs. - 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
AllegianceUpdatepackets. - 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):
- Capture an
AllegianceUpdatepacket from a live ACE shard. - Decode it with acdream's port of the hierarchy reader.
- Spot-verify every field matches what ACE's writer would have produced for the same node.
- Build the tree renderer on top of that model.
- Add outbound actions one at a time (start with
AllegianceUpdateRequest— it's the simplest and used most).