diff --git a/src/AcDream.Core.Net/Messages/AllegianceRequests.cs b/src/AcDream.Core.Net/Messages/AllegianceRequests.cs
new file mode 100644
index 0000000..a7bac79
--- /dev/null
+++ b/src/AcDream.Core.Net/Messages/AllegianceRequests.cs
@@ -0,0 +1,57 @@
+using System.Buffers.Binary;
+
+namespace AcDream.Core.Net.Messages;
+
+///
+/// Outbound allegiance GameActions. Both Swear and Break carry a
+/// single uint32 target-guid payload inside the standard
+/// 0xF7B1 GameAction envelope.
+///
+///
+/// Wire layout (r11 §2.1 / §2.3):
+///
+/// u32 0xF7B1
+/// u32 gameActionSequence
+/// u32 subOpcode (0x001D or 0x001E)
+/// u32 targetGuid
+///
+///
+///
+///
+/// Server replies with GameEventAllegianceUpdate (0x0020) and
+/// GameEventAllegianceAllegianceUpdateDone (0x01C8) on success,
+/// or a WeenieError on failure (already sworn, already maxed
+/// vassals, target not online, etc — see r11 §2.1 for the full list).
+///
+///
+public static class AllegianceRequests
+{
+ public const uint GameActionEnvelope = 0xF7B1u;
+ public const uint SwearOpcode = 0x001Du;
+ public const uint BreakOpcode = 0x001Eu;
+
+ /// Pledge yourself to the given patron.
+ public static byte[] BuildSwear(uint gameActionSequence, uint patronGuid)
+ {
+ return Build(gameActionSequence, SwearOpcode, patronGuid);
+ }
+
+ ///
+ /// Break your pledge to . Target can be
+ /// your patron (breaking from) OR your vassal (breaking them away).
+ ///
+ public static byte[] BuildBreak(uint gameActionSequence, uint targetGuid)
+ {
+ return Build(gameActionSequence, BreakOpcode, targetGuid);
+ }
+
+ private static byte[] Build(uint seq, uint sub, uint targetGuid)
+ {
+ byte[] body = new byte[16];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), sub);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), targetGuid);
+ return body;
+ }
+}
diff --git a/src/AcDream.Core/Allegiance/AllegianceTree.cs b/src/AcDream.Core/Allegiance/AllegianceTree.cs
new file mode 100644
index 0000000..2d68dd9
--- /dev/null
+++ b/src/AcDream.Core/Allegiance/AllegianceTree.cs
@@ -0,0 +1,183 @@
+using System;
+using System.Collections.Generic;
+
+namespace AcDream.Core.Allegiance;
+
+///
+/// Per-character allegiance node. Retail allegiance is a strict tree:
+/// one patron per character, max 11 direct vassals, recursive depth.
+/// Rank capped at 10 (r11 §1, §4).
+///
+///
+/// Nodes know their own name + rank + allegiance XP pass-through
+/// history. The root (monarch) has = 0.
+///
+///
+public sealed class AllegianceNode
+{
+ public uint Guid;
+ public string Name = "";
+ public uint PatronGuid; // 0 if this node IS the monarch
+ public int Rank; // 0 = unpledged / monarch; capped at 10
+ public long ExperienceCached; // XP the client has seen pass up so far
+ public List VassalGuids { get; } = new(); // direct vassals only
+}
+
+///
+/// Client-side mirror of the player's allegiance tree. Updates come
+/// from
+/// (0x0020). The full wire blob is complex — r11 §5 — so this class
+/// exposes a minimal API the UI can grow into:
+///
+///
+/// -
+/// UpsertNode — called by the AllegianceUpdate handler
+/// when the server pushes a node refresh.
+///
+/// -
+/// SetMonarch — establishes the root.
+///
+/// -
+/// RemoveNode — handles break / leave.
+///
+/// -
+/// GetAncestors — walks up to the monarch.
+///
+/// -
+/// GetDescendants — flattens the subtree.
+///
+///
+///
+///
+/// Thread safety: single-threaded use from the render thread. Clone
+/// before reading from another thread.
+///
+///
+public sealed class AllegianceTree
+{
+ private readonly Dictionary _byGuid = new();
+
+ public uint MonarchGuid { get; private set; }
+ public uint PlayerGuid { get; set; } // whose-allegiance-this-is
+
+ public event Action? TreeChanged;
+
+ public int NodeCount => _byGuid.Count;
+
+ public AllegianceNode? Get(uint guid) =>
+ _byGuid.TryGetValue(guid, out var n) ? n : null;
+
+ public void SetMonarch(uint guid, string name)
+ {
+ MonarchGuid = guid;
+ var node = UpsertNodeInternal(guid, name, 0, rank: 0);
+ TreeChanged?.Invoke();
+ }
+
+ public AllegianceNode UpsertNode(uint guid, string name, uint patronGuid, int rank)
+ {
+ var node = UpsertNodeInternal(guid, name, patronGuid, rank);
+ TreeChanged?.Invoke();
+ return node;
+ }
+
+ private AllegianceNode UpsertNodeInternal(uint guid, string name, uint patronGuid, int rank)
+ {
+ if (!_byGuid.TryGetValue(guid, out var node))
+ {
+ node = new AllegianceNode { Guid = guid };
+ _byGuid[guid] = node;
+ }
+ node.Name = name;
+ node.PatronGuid = patronGuid;
+ node.Rank = Math.Clamp(rank, 0, 10);
+
+ // Keep parent's vassal list in sync with this node's declared
+ // patron. A node that changes patron is removed from the old
+ // patron's list and added to the new one.
+ if (patronGuid != 0 && _byGuid.TryGetValue(patronGuid, out var parent))
+ {
+ if (!parent.VassalGuids.Contains(guid))
+ parent.VassalGuids.Add(guid);
+ }
+
+ return node;
+ }
+
+ public bool RemoveNode(uint guid)
+ {
+ if (!_byGuid.TryGetValue(guid, out var node)) return false;
+ if (node.PatronGuid != 0 && _byGuid.TryGetValue(node.PatronGuid, out var parent))
+ parent.VassalGuids.Remove(guid);
+ _byGuid.Remove(guid);
+ // Cascade: orphaned descendants stay in the map with their
+ // patron pointer dangling; the UI can hide them or the next
+ // AllegianceUpdate will refresh them.
+ TreeChanged?.Invoke();
+ return true;
+ }
+
+ ///
+ /// Walk from up to the monarch. Yields node
+ /// then patron then grand-patron, etc. Includes the starting node.
+ /// Stops if a cycle is detected (shouldn't happen — server enforces
+ /// acyclicity — but be defensive).
+ ///
+ public IEnumerable GetAncestors(uint guid)
+ {
+ var seen = new HashSet();
+ uint cursor = guid;
+ while (cursor != 0 && seen.Add(cursor) && _byGuid.TryGetValue(cursor, out var node))
+ {
+ yield return node;
+ cursor = node.PatronGuid;
+ }
+ }
+
+ ///
+ /// Flatten the subtree rooted at in BFS
+ /// order. Includes the root itself.
+ ///
+ public IEnumerable GetDescendants(uint rootGuid)
+ {
+ if (!_byGuid.TryGetValue(rootGuid, out var root)) yield break;
+ var queue = new Queue();
+ queue.Enqueue(root);
+ while (queue.Count > 0)
+ {
+ var node = queue.Dequeue();
+ yield return node;
+ foreach (var vGuid in node.VassalGuids)
+ if (_byGuid.TryGetValue(vGuid, out var vassal))
+ queue.Enqueue(vassal);
+ }
+ }
+
+ public void Clear()
+ {
+ _byGuid.Clear();
+ MonarchGuid = 0;
+ TreeChanged?.Invoke();
+ }
+}
+
+///
+/// Retail XP passup formula (r11 §3.2). Vassal passes up a fraction of
+/// their earned XP each level:
+///
+/// passup = ((50 + 22.5 × loyaltyLevel) / 291) ×
+/// (1 + realTimeDays/730 × inGameDays/720) × earnedXp
+///
+///
+public static class AllegianceMath
+{
+ public static long ComputePassup(long earnedXp, int loyaltyLevel,
+ double realTimeDays, double inGameDays)
+ {
+ double loyaltyScale = (50.0 + 22.5 * loyaltyLevel) / 291.0;
+ double ageScale = 1.0 + (realTimeDays / 730.0) * (inGameDays / 720.0);
+ double passup = earnedXp * loyaltyScale * ageScale;
+ if (passup < 0) passup = 0;
+ return (long)Math.Round(passup);
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/AllegianceRequestsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/AllegianceRequestsTests.cs
new file mode 100644
index 0000000..848d79c
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Messages/AllegianceRequestsTests.cs
@@ -0,0 +1,35 @@
+using System;
+using System.Buffers.Binary;
+using AcDream.Core.Net.Messages;
+using Xunit;
+
+namespace AcDream.Core.Net.Tests.Messages;
+
+public sealed class AllegianceRequestsTests
+{
+ [Fact]
+ public void BuildSwear_EncodesOpcodeAndTarget()
+ {
+ byte[] body = AllegianceRequests.BuildSwear(gameActionSequence: 3, patronGuid: 0xAAAAu);
+
+ Assert.Equal(16, body.Length);
+ Assert.Equal(AllegianceRequests.GameActionEnvelope,
+ BinaryPrimitives.ReadUInt32LittleEndian(body));
+ Assert.Equal(3u,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)));
+ Assert.Equal(AllegianceRequests.SwearOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
+ Assert.Equal(0xAAAAu,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
+ }
+
+ [Fact]
+ public void BuildBreak_EncodesOpcodeAndTarget()
+ {
+ byte[] body = AllegianceRequests.BuildBreak(gameActionSequence: 5, targetGuid: 0xBBBBu);
+ Assert.Equal(AllegianceRequests.BreakOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
+ Assert.Equal(0xBBBBu,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Allegiance/AllegianceTreeTests.cs b/tests/AcDream.Core.Tests/Allegiance/AllegianceTreeTests.cs
new file mode 100644
index 0000000..826e3fa
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Allegiance/AllegianceTreeTests.cs
@@ -0,0 +1,110 @@
+using System.Linq;
+using AcDream.Core.Allegiance;
+using Xunit;
+
+namespace AcDream.Core.Tests.Allegiance;
+
+public sealed class AllegianceTreeTests
+{
+ [Fact]
+ public void SetMonarch_RegistersNode_FiresEvent()
+ {
+ var tree = new AllegianceTree();
+ int changes = 0;
+ tree.TreeChanged += () => changes++;
+
+ tree.SetMonarch(0xAA, "Bael'Zharon");
+ Assert.Equal(1, changes);
+ Assert.Equal(0xAAu, tree.MonarchGuid);
+ Assert.Equal(1, tree.NodeCount);
+ }
+
+ [Fact]
+ public void UpsertNode_NewPatron_AddsToVassalList()
+ {
+ var tree = new AllegianceTree();
+ tree.SetMonarch(0xAA, "Monarch");
+ tree.UpsertNode(0xBB, "Vassal", patronGuid: 0xAA, rank: 1);
+
+ var monarch = tree.Get(0xAA);
+ Assert.NotNull(monarch);
+ Assert.Contains(0xBBu, monarch!.VassalGuids);
+ }
+
+ [Fact]
+ public void UpsertNode_RankClamp_CapsAt10()
+ {
+ var tree = new AllegianceTree();
+ tree.SetMonarch(0xAA, "M");
+ var node = tree.UpsertNode(0xBB, "V", 0xAA, rank: 50);
+ Assert.Equal(10, node.Rank);
+ }
+
+ [Fact]
+ public void RemoveNode_RemovesFromParentVassalList()
+ {
+ var tree = new AllegianceTree();
+ tree.SetMonarch(0xAA, "M");
+ tree.UpsertNode(0xBB, "V", 0xAA, 1);
+ Assert.Single(tree.Get(0xAA)!.VassalGuids);
+
+ tree.RemoveNode(0xBB);
+ Assert.Empty(tree.Get(0xAA)!.VassalGuids);
+ }
+
+ [Fact]
+ public void GetAncestors_WalksToMonarch()
+ {
+ var tree = new AllegianceTree();
+ tree.SetMonarch(0xAA, "M");
+ tree.UpsertNode(0xBB, "V", 0xAA, 1);
+ tree.UpsertNode(0xCC, "VV", 0xBB, 2);
+ tree.UpsertNode(0xDD, "VVV", 0xCC, 3);
+
+ var chain = tree.GetAncestors(0xDD).Select(n => n.Name).ToArray();
+ Assert.Equal(new[] { "VVV", "VV", "V", "M" }, chain);
+ }
+
+ [Fact]
+ public void GetAncestors_HandlesCycleWithoutLooping()
+ {
+ // Corrupt data: defensive path. Treat a cycle as a stop.
+ var tree = new AllegianceTree();
+ tree.UpsertNode(0xAA, "A", patronGuid: 0xBB, rank: 1);
+ tree.UpsertNode(0xBB, "B", patronGuid: 0xAA, rank: 1); // cycle
+
+ var walk = tree.GetAncestors(0xAA).Select(n => n.Guid).ToArray();
+ Assert.Equal(2, walk.Length); // visits each exactly once
+ }
+
+ [Fact]
+ public void GetDescendants_BfsOrder()
+ {
+ var tree = new AllegianceTree();
+ tree.SetMonarch(0xAA, "M");
+ tree.UpsertNode(0xB0, "B0", 0xAA, 1);
+ tree.UpsertNode(0xB1, "B1", 0xAA, 1);
+ tree.UpsertNode(0xC0, "C0", 0xB0, 2);
+
+ var bfs = tree.GetDescendants(0xAA).Select(n => n.Name).ToArray();
+ Assert.Equal(new[] { "M", "B0", "B1", "C0" }, bfs);
+ }
+
+ [Fact]
+ public void AllegianceMath_Passup_KnownCase()
+ {
+ // 1000 XP, loyalty 10, 100 real-time days, 100 in-game days.
+ // loyaltyScale = (50 + 225) / 291 = 0.9450
+ // ageScale = 1 + (100/730)*(100/720) = 1 + 0.0190 = 1.0190
+ // passup = 1000 * 0.9450 * 1.0190 ≈ 963
+ long passup = AllegianceMath.ComputePassup(1000, 10, 100, 100);
+ Assert.InRange(passup, 960, 965);
+ }
+
+ [Fact]
+ public void AllegianceMath_Passup_ZeroClamp()
+ {
+ // Negative XP shouldn't be possible; passup is clamped at 0.
+ Assert.Equal(0, AllegianceMath.ComputePassup(-1000, 10, 0, 0));
+ }
+}