diff --git a/src/AcDream.Core.Net/Messages/ClientCommandResponses.cs b/src/AcDream.Core.Net/Messages/ClientCommandResponses.cs
index 3d765735..43e32719 100644
--- a/src/AcDream.Core.Net/Messages/ClientCommandResponses.cs
+++ b/src/AcDream.Core.Net/Messages/ClientCommandResponses.cs
@@ -152,33 +152,147 @@ public static class ClientCommandResponses
}
}
- // ── 0x027C AllegianceInfoResponse ───────────────────────────────────────
+ // ── 0x027C AllegianceInfoResponse / 0x0020 AllegianceUpdate ─────────────
// ACE: GameEventAllegianceInfoResponse.cs -> AllegianceProfileExtensions.
// Write / AllegianceHierarchyExtensions.Write / AllegianceDataExtensions.
// Write. Retail: ClientAllegianceSystem::
// Handle_Allegiance__AllegianceInfoResponseEvent @0x0056a1d0, walking
// AllegianceProfile::GetData/GetPatron/GetFirstVassal/GetNextVassal.
+ //
+ // Campaign FA slice FA1 (2026-08-11): extended with the ELEVEN
+ // AllegianceHierarchy::UnPack version gates
+ // (docs/research/2026-08-11-fa-allegiance-wire.md, lane C, §4.2) and
+ // the tree-assembly rules (§4.4: an orphan treeParent — one not
+ // already in the tree — discards the WHOLE message; sibling order
+ // REVERSES on assembly), then factored into a shared
+ // ReadAllegianceProfileBody used by BOTH this parser (leading u32 =
+ // targetGuid) and the new ParseAllegianceUpdate (leading u32 = rank) —
+ // lane C §7.2's explicit reuse verdict: extend this parser, do not
+ // write a second one. ACE always writes oldVersion 0x000B (newest), so
+ // every gate below is exercised in practice; the gates exist so a
+ // parser doesn't silently misread a hypothetical older-version blob.
///
- /// One retail AllegianceData record.
- /// is the wire's "treeParent" tag (0 for the monarch, who has none) —
- /// retail's own GetPatron/GetFirstVassal walk the flat
- /// record list by this tag rather than storing an explicit tree.
+ /// One retail AllegianceData record (lane C §4.1). is the wire's "treeParent" tag (0 for the
+ /// monarch, who has none) — retail's own GetPatron/
+ /// GetFirstVassal walk the flat record list by this tag rather
+ /// than storing an explicit tree. The trailing fields (FA1) were
+ /// previously read and discarded — they are exactly the columns lane C
+ /// §7.2 names as needed once a panel exists; TimeOnline/AllegianceAge
+ /// remain unsurfaced because ACE hard-codes both to 0 forever (lane C
+ /// §5.1), so surfacing them would only ever show zero. Trailing
+ /// defaults keep the pre-FA1 4-arg positional construction sites
+ /// (tests, historically) compiling unchanged.
///
public readonly record struct AllegianceMemberRecord(
uint CharacterId,
uint ParentGuid,
bool IsLoggedIn,
- string Name);
+ string Name,
+ ushort Rank = 0,
+ uint Level = 0,
+ ushort Loyalty = 0,
+ ushort Leadership = 0,
+ uint CpCached = 0,
+ uint CpTithed = 0,
+ byte Gender = 0,
+ byte HeritageGroup = 0,
+ bool MayPassupExperience = false);
///
/// /HasAllegianceAge/
- /// HasPackedLevel bit values — ACE
- /// Source/ACE.Server/Network/Enum/AllegianceIndex.cs.
+ /// HasPackedLevel/MayPassupExperience bit values — ACE
+ /// Source/ACE.Server/Network/Enum/AllegianceIndex.cs, matching
+ /// retail's own enum verbatim (acclient.h:7714-7722).
///
private const uint LoggedInBit = 0x1u;
private const uint HasAllegianceAgeBit = 0x4u;
private const uint HasPackedLevelBit = 0x8u;
+ private const uint MayPassupExperienceBit = 0x10u;
+
+ ///
+ /// The version-gated hierarchy-level fields plus the assembled record
+ /// list — the parts of AllegianceProfile/AllegianceHierarchy
+ /// that do NOT differ between 0x027C (leading guid) and
+ /// 0x0020 (leading rank). Officers / officer titles / the four
+ /// monarch-and-spokes broadcast counters / the bind point are read (so
+ /// every later field lands at the correct offset regardless of
+ /// version) but not surfaced on the public records: ACE deliberately
+ /// zeroes or empties officers/titles/lock/approvedVassal/broadcast
+ /// counters (lane C §5.1) and the bind point is a 32-byte
+ /// Position the retail chat renderer never reads either — a
+ /// panel that needs the bind point can extend this record without
+ /// re-deriving the parse (deferred, not dropped).
+ ///
+ private readonly record struct AllegianceProfileBody(
+ uint TotalMembers,
+ uint TotalVassals,
+ ushort RecordCount,
+ ushort OldVersion,
+ string Motd,
+ string MotdSetBy,
+ uint ChatRoomId,
+ string AllegianceName,
+ uint NameLastSetTime,
+ bool IsLocked,
+ uint ApprovedVassal,
+ AllegianceMemberRecord? Monarch,
+ IReadOnlyList Records);
+
+ ///
+ /// Shared lookup logic for both
+ /// and — the flat record list plus
+ /// ParentGuid tags IS the tree (lane C §0's DELETE verdict on
+ /// Core/Allegiance/AllegianceTree.cs); these are ports of
+ /// retail's own pointer-walk accessors (lane C §1.5).
+ ///
+ private static class AllegianceProfileLookups
+ {
+ /// Port of AllegianceProfile::GetData.
+ public static AllegianceMemberRecord? FindData(
+ AllegianceMemberRecord? monarch,
+ IReadOnlyList records,
+ uint guid)
+ {
+ if (monarch is { } m && m.CharacterId == guid) return monarch;
+ foreach (AllegianceMemberRecord record in records)
+ if (record.CharacterId == guid) return record;
+ return null;
+ }
+
+ ///
+ /// Port of AllegianceProfile::GetPatron: the monarch has no
+ /// patron; anyone else's patron is applied
+ /// to their own record's .
+ ///
+ public static AllegianceMemberRecord? FindPatron(
+ AllegianceMemberRecord? monarch,
+ IReadOnlyList records,
+ uint guid)
+ {
+ if (monarch is { } m && m.CharacterId == guid) return null;
+ foreach (AllegianceMemberRecord record in records)
+ if (record.CharacterId == guid) return FindData(monarch, records, record.ParentGuid);
+ return null;
+ }
+
+ ///
+ /// Port of GetFirstVassal/GetNextVassal. Lane C §4.4
+ /// point 3: each new record is PREPENDED to its parent's vassal
+ /// list on assembly (_peer = parent->_vassal; parent->_vassal
+ /// = node;), so the walk — and therefore the panel's vassal
+ /// list box — visits siblings in REVERSE wire order: the record
+ /// parsed LAST under a given parent renders first.
+ ///
+ public static IEnumerable FindVassals(
+ IReadOnlyList records, uint guid)
+ {
+ for (int i = records.Count - 1; i >= 0; i--)
+ if (records[i].ParentGuid == guid)
+ yield return records[i];
+ }
+ }
public readonly record struct AllegianceInfoResponse(
uint TargetGuid,
@@ -187,55 +301,23 @@ public static class ClientCommandResponses
ushort RecordCount,
string AllegianceName,
AllegianceMemberRecord? Monarch,
- IReadOnlyList Records)
+ IReadOnlyList Records,
+ ushort OldVersion = 0,
+ string Motd = "",
+ string MotdSetBy = "",
+ uint ChatRoomId = 0,
+ uint NameLastSetTime = 0,
+ bool IsLocked = false,
+ uint ApprovedVassal = 0)
{
- ///
- /// Port of AllegianceProfile::GetData: find the record
- /// (monarch or otherwise) whose own characterID matches
- /// .
- ///
- public AllegianceMemberRecord? FindData(uint guid)
- {
- if (Monarch is { } monarch && monarch.CharacterId == guid)
- return monarch;
- foreach (AllegianceMemberRecord record in Records)
- {
- if (record.CharacterId == guid)
- return record;
- }
- return null;
- }
+ public AllegianceMemberRecord? FindData(uint guid) =>
+ AllegianceProfileLookups.FindData(Monarch, Records, guid);
- ///
- /// Port of AllegianceProfile::GetPatron: the monarch has no
- /// patron; anyone else's patron is applied to
- /// their own record's
- /// (which is the monarch's own guid when the patron IS the monarch —
- /// ACE never emits a separate patron record in that case, see
- /// AllegianceHierarchy.Write's !node.Patron.IsMonarch
- /// guard).
- ///
- public AllegianceMemberRecord? FindPatron(uint guid)
- {
- if (Monarch is { } monarch && monarch.CharacterId == guid)
- return null;
- foreach (AllegianceMemberRecord record in Records)
- {
- if (record.CharacterId == guid)
- return FindData(record.ParentGuid);
- }
- return null;
- }
+ public AllegianceMemberRecord? FindPatron(uint guid) =>
+ AllegianceProfileLookups.FindPatron(Monarch, Records, guid);
- /// Port of GetFirstVassal/GetNextVassal: every record whose parent is .
- public IEnumerable FindVassals(uint guid)
- {
- foreach (AllegianceMemberRecord record in Records)
- {
- if (record.ParentGuid == guid)
- yield return record;
- }
- }
+ public IEnumerable FindVassals(uint guid) =>
+ AllegianceProfileLookups.FindVassals(Records, guid);
}
public static AllegianceInfoResponse? ParseAllegianceInfoResponse(ReadOnlySpan payload)
@@ -244,95 +326,248 @@ public static class ClientCommandResponses
{
int pos = 0;
uint targetGuid = ReadU32(payload, ref pos);
- uint totalMembers = ReadU32(payload, ref pos);
- uint totalVassals = ReadU32(payload, ref pos);
- ushort recordCount = ReadU16(payload, ref pos);
- _ = ReadU16(payload, ref pos); // oldVersion — not consulted by the renderer
+ AllegianceProfileBody? body = ReadAllegianceProfileBody(payload, ref pos);
+ if (body is null) return null; // §4.4 orphan/self-parent/duplicate — retail discards the WHOLE message
+ AllegianceProfileBody b = body.Value;
+ return new AllegianceInfoResponse(
+ targetGuid, b.TotalMembers, b.TotalVassals, b.RecordCount,
+ b.AllegianceName, b.Monarch, b.Records,
+ b.OldVersion, b.Motd, b.MotdSetBy, b.ChatRoomId, b.NameLastSetTime,
+ b.IsLocked, b.ApprovedVassal);
+ }
+ catch (FormatException) { return null; }
+ }
- // officers: PackableHashTable.
- // ACE always sends this empty ("always sent as empty in retail?"
- // per AllegianceHierarchy.cs) and retail's own chat renderer never
- // reads it — skip the entries, keep the cursor faithful.
+ ///
+ /// 0x0020 AllegianceUpdate — the unsolicited/subscribed profile
+ /// push (lane C §2 row 5, §4.5): a leading u32 rank, then the
+ /// SAME AllegianceProfile body 0x027C carries. Pushed on
+ /// every tree change to every online member regardless of whether the
+ /// panel ever sent 0x001F (lane C §5.2) — a client that never
+ /// subscribes still receives it.
+ ///
+ public readonly record struct AllegianceUpdate(
+ uint Rank,
+ uint TotalMembers,
+ uint TotalVassals,
+ ushort RecordCount,
+ string AllegianceName,
+ AllegianceMemberRecord? Monarch,
+ IReadOnlyList Records,
+ ushort OldVersion = 0,
+ string Motd = "",
+ string MotdSetBy = "",
+ uint ChatRoomId = 0,
+ uint NameLastSetTime = 0,
+ bool IsLocked = false,
+ uint ApprovedVassal = 0)
+ {
+ public AllegianceMemberRecord? FindData(uint guid) =>
+ AllegianceProfileLookups.FindData(Monarch, Records, guid);
+
+ public AllegianceMemberRecord? FindPatron(uint guid) =>
+ AllegianceProfileLookups.FindPatron(Monarch, Records, guid);
+
+ public IEnumerable FindVassals(uint guid) =>
+ AllegianceProfileLookups.FindVassals(Records, guid);
+ }
+
+ public static AllegianceUpdate? ParseAllegianceUpdate(ReadOnlySpan payload)
+ {
+ try
+ {
+ int pos = 0;
+ uint rank = ReadU32(payload, ref pos);
+ AllegianceProfileBody? body = ReadAllegianceProfileBody(payload, ref pos);
+ if (body is null) return null;
+ AllegianceProfileBody b = body.Value;
+ return new AllegianceUpdate(
+ rank, b.TotalMembers, b.TotalVassals, b.RecordCount,
+ b.AllegianceName, b.Monarch, b.Records,
+ b.OldVersion, b.Motd, b.MotdSetBy, b.ChatRoomId, b.NameLastSetTime,
+ b.IsLocked, b.ApprovedVassal);
+ }
+ catch (FormatException) { return null; }
+ }
+
+ ///
+ /// Reads everything after the profile's leading discriminator u32
+ /// (targetGuid for 0x027C, rank for 0x0020) — the eleven
+ /// version gates (lane C §4.2) followed by the monarch + record list
+ /// with the §4.4 tree-assembly rules enforced. Returns (never throws) when a record's treeParent is
+ /// orphaned, self-referential, or a duplicate id — the same "discard
+ /// the whole message" outcome AllegianceHierarchy::UnPack
+ /// produces on an Add failure. Truncation still throws
+ /// , caught by both callers' try/catch.
+ ///
+ private static AllegianceProfileBody? ReadAllegianceProfileBody(ReadOnlySpan payload, ref int pos)
+ {
+ uint totalMembers = ReadU32(payload, ref pos);
+ uint totalVassals = ReadU32(payload, ref pos);
+ ushort recordCount = ReadU16(payload, ref pos);
+ ushort oldVersion = ReadU16(payload, ref pos);
+
+ // §4.2 gates 1/2: officers (oldVersion >= 6,
+ // MultipleAllegianceOfficersAdded) vs the legacy single
+ // spokesperson-id 4-byte skip (1 <= oldVersion < 6). Entries are
+ // consumed but not surfaced (ACE always sends officers empty —
+ // lane C §5.1) so every later field still lands correctly.
+ if (oldVersion >= 6)
+ {
ushort officerCount = ReadU16(payload, ref pos);
- _ = ReadU16(payload, ref pos); // numBuckets
+ _ = ReadU16(payload, ref pos); // numBuckets — server-chosen, not consulted
for (int i = 0; i < officerCount; i++)
{
_ = ReadU32(payload, ref pos); // guid
_ = ReadU32(payload, ref pos); // officer level
}
+ }
+ else if (oldVersion >= 1)
+ {
+ _ = ReadU32(payload, ref pos); // old single spokesperson id
+ }
- // officerTitles: List.Write — a bare int32 count (NOT the
- // PackableHashTable u16/u16 header), then N String16L.
+ // §4.2 gate 3: officer titles (oldVersion >= 9,
+ // OfficersTitlesAdded) — PSmartArray: a bare i32 count,
+ // NOT the PackableHashTable u16/u16 header.
+ if (oldVersion >= 9)
+ {
int titleCount = unchecked((int)ReadU32(payload, ref pos));
for (int i = 0; i < titleCount; i++)
_ = StringReader.ReadString16L(payload, ref pos);
+ }
+ // §4.2 gate 4 (PoolsAdded, oldVersion >= 2): four broadcast counters.
+ if (oldVersion >= 2)
+ {
_ = ReadU32(payload, ref pos); // monarchBroadcastTime
_ = ReadU32(payload, ref pos); // monarchBroadcastsToday
_ = ReadU32(payload, ref pos); // spokesBroadcastTime
_ = ReadU32(payload, ref pos); // spokesBroadcastsToday
- _ = StringReader.ReadString16L(payload, ref pos); // motd
- _ = StringReader.ReadString16L(payload, ref pos); // motdSetBy
- _ = ReadU32(payload, ref pos); // chatRoomID
+ }
- // bindPoint Position: cell(u32) + pos(3xfloat) + rotation(4xfloat,
- // W/X/Y/Z order) = 32 bytes. Not surfaced by the retail chat
- // renderer (only the Allegiance UI panel's bind-point display
- // would use it) — skip with bounds checking via ReadU32.
+ // §4.2 gate 5 (MotdAdded, oldVersion >= 3).
+ string motd = "";
+ string motdSetBy = "";
+ if (oldVersion >= 3)
+ {
+ motd = StringReader.ReadString16L(payload, ref pos);
+ motdSetBy = StringReader.ReadString16L(payload, ref pos);
+ }
+
+ // §4.2 gate 6 (ChatRoomIDAdded, oldVersion >= 4).
+ uint chatRoomId = 0;
+ if (oldVersion >= 4)
+ chatRoomId = ReadU32(payload, ref pos);
+
+ // §4.2 gate 7 (Bindstones, oldVersion >= 7): Position =
+ // cell(u32) + pos(3xfloat) + rotation(4xfloat, W/X/Y/Z) = 32
+ // bytes. Skipped, not surfaced — see the class doc on
+ // AllegianceProfileBody for why.
+ if (oldVersion >= 7)
+ {
for (int i = 0; i < 8; i++)
_ = ReadU32(payload, ref pos);
-
- string allegianceName = StringReader.ReadString16L(payload, ref pos);
- _ = ReadU32(payload, ref pos); // nameLastSetTime
- _ = ReadU32(payload, ref pos); // isLocked
- _ = ReadU32(payload, ref pos); // approvedVassal
-
- AllegianceMemberRecord? monarch = null;
- var records = new List();
- if (recordCount > 0)
- {
- monarch = ReadAllegianceData(payload, ref pos, parentGuid: 0u);
- for (int i = 1; i < recordCount; i++)
- {
- uint parentGuid = ReadU32(payload, ref pos);
- records.Add(ReadAllegianceData(payload, ref pos, parentGuid));
- }
- }
-
- return new AllegianceInfoResponse(
- targetGuid, totalMembers, totalVassals, recordCount,
- allegianceName, monarch, records);
}
- catch (FormatException) { return null; }
+
+ // §4.2 gate 8 (AllegianceName, oldVersion >= 8).
+ string allegianceName = "";
+ uint nameLastSetTime = 0;
+ if (oldVersion >= 8)
+ {
+ allegianceName = StringReader.ReadString16L(payload, ref pos);
+ nameLastSetTime = ReadU32(payload, ref pos);
+ }
+
+ // §4.2 gate 9 (LockedState, oldVersion >= 10).
+ bool isLocked = false;
+ if (oldVersion >= 10)
+ isLocked = ReadU32(payload, ref pos) != 0u;
+
+ // §4.2 gate 10 (ApprovedVassal, oldVersion >= 11).
+ uint approvedVassal = 0;
+ if (oldVersion >= 11)
+ approvedVassal = ReadU32(payload, ref pos);
+
+ // §4.4 tree assembly: the monarch record (no treeParent on the
+ // wire, never version-gated) followed by (recordCount-1) records
+ // each carrying an explicit treeParent.
+ // AllegianceHierarchy::Add @0x005B6E90 discards the WHOLE message
+ // if a treeParent is not already in the tree (orphan), equals the
+ // record's own id (self-parent), or duplicates an id already
+ // seen — modeled here as a running knownIds set; any failure
+ // returns null rather than a partial/corrupted tree.
+ AllegianceMemberRecord? monarch = null;
+ var records = new List();
+ if (recordCount > 0)
+ {
+ AllegianceMemberRecord monarchRecord = ReadAllegianceData(payload, ref pos, parentGuid: 0u);
+ monarch = monarchRecord;
+ var knownIds = new HashSet { monarchRecord.CharacterId };
+
+ for (int i = 1; i < recordCount; i++)
+ {
+ uint parentGuid = ReadU32(payload, ref pos);
+ AllegianceMemberRecord record = ReadAllegianceData(payload, ref pos, parentGuid);
+
+ if (!knownIds.Contains(parentGuid)
+ || parentGuid == record.CharacterId
+ || knownIds.Contains(record.CharacterId))
+ {
+ return null;
+ }
+
+ knownIds.Add(record.CharacterId);
+ records.Add(record);
+ }
+ }
+
+ return new AllegianceProfileBody(
+ totalMembers, totalVassals, recordCount, oldVersion,
+ motd, motdSetBy, chatRoomId, allegianceName, nameLastSetTime,
+ isLocked, approvedVassal, monarch, records);
}
private static AllegianceMemberRecord ReadAllegianceData(
ReadOnlySpan payload, ref int pos, uint parentGuid)
{
uint characterId = ReadU32(payload, ref pos);
- _ = ReadU32(payload, ref pos); // cpCached
- _ = ReadU32(payload, ref pos); // cpTithed
+ uint cpCached = ReadU32(payload, ref pos);
+ uint cpTithed = ReadU32(payload, ref pos);
uint bitfield = ReadU32(payload, ref pos);
- _ = ReadByte(payload, ref pos); // gender
- _ = ReadByte(payload, ref pos); // heritage group
- _ = ReadU16(payload, ref pos); // rank
+ byte gender = ReadByte(payload, ref pos);
+ byte heritageGroup = ReadByte(payload, ref pos);
+ ushort rank = ReadU16(payload, ref pos);
+ uint level = 0;
if ((bitfield & HasPackedLevelBit) != 0u)
- _ = ReadU32(payload, ref pos); // level
- _ = ReadU16(payload, ref pos); // loyalty
- _ = ReadU16(payload, ref pos); // leadership
+ level = ReadU32(payload, ref pos);
+ ushort loyalty = ReadU16(payload, ref pos);
+ ushort leadership = ReadU16(payload, ref pos);
if ((bitfield & HasAllegianceAgeBit) != 0u)
{
- _ = ReadU32(payload, ref pos); // timeOnline
- _ = ReadU32(payload, ref pos); // allegianceAge
+ _ = ReadU32(payload, ref pos); // timeOnline — ACE hard-codes 0 forever (lane C §5.1)
+ _ = ReadU32(payload, ref pos); // allegianceAge — same
}
else
{
- _ = ReadU32(payload, ref pos); // uTimeOnline low
- _ = ReadU32(payload, ref pos); // uTimeOnline high
+ _ = ReadU32(payload, ref pos); // legacy uTimeOnline low (double, pre-HasAllegianceAge)
+ _ = ReadU32(payload, ref pos); // legacy uTimeOnline high
}
string name = StringReader.ReadString16L(payload, ref pos);
+
+ // Lane C §4.1 point 1: when HasPackedLevel is absent, retail's
+ // client sets MayPassupExperience itself regardless of the wire
+ // bit — legacy-packet compatibility. Harmless against ACE (which
+ // always sets HasPackedLevel) but ported for fidelity: a port
+ // that also sets the bit is MORE faithful than one that does not.
+ bool mayPassupExperience = (bitfield & MayPassupExperienceBit) != 0u
+ || (bitfield & HasPackedLevelBit) == 0u;
+
return new AllegianceMemberRecord(
- characterId, parentGuid, (bitfield & LoggedInBit) != 0u, name);
+ characterId, parentGuid, (bitfield & LoggedInBit) != 0u, name,
+ rank, level, loyalty, leadership, cpCached, cpTithed,
+ gender, heritageGroup, mayPassupExperience);
}
///
diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs
index 9a95757e..d6aacf00 100644
--- a/src/AcDream.Core.Net/Messages/GameEvents.cs
+++ b/src/AcDream.Core.Net/Messages/GameEvents.cs
@@ -539,6 +539,324 @@ public static class GameEvents
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
}
+ ///
+ /// The ConfirmationType discriminator carried by all three legs
+ /// of the shared confirmation triple (0x0274/0x0275/
+ /// 0x0276's leading Type/confirmationType field).
+ /// Retail Handle_Character__ConfirmationRequest @0x005640A0's
+ /// switch and ACE's ConfirmationType.cs:5-12 agree byte-for-byte
+ /// (lane B §3.15, lane C §1.3). Campaign FA needs exactly
+ /// (1) and (4)
+ /// — D6.
+ ///
+ public enum ConfirmationType : uint
+ {
+ SwearAllegiance = 1,
+ AlterSkill = 2,
+ AlterAttribute = 3,
+ Fellowship = 4,
+ CraftInteraction = 5,
+ Augmentation = 6,
+ YesNo = 7,
+ }
+
+ ///
+ /// 0x0275 ConfirmationResponse — the CLIENT→SERVER leg of the
+ /// confirmation triple (CM_Character::Event_ConfirmationResponse
+ /// @0x006A1210, lane B §3.15 / lane C §3.3). Unlike
+ /// /
+ /// this direction is never received by a real client — acdream already
+ /// builds it (ClientCommandRequests.BuildConfirmationResponse).
+ /// This record + parser exist to give the triple a complete, TYPED
+ /// representation in Core.Net (the
+ /// enum, not a bare uint) and a round-trip conformance check —
+ /// see ConfirmationResponseTests for the golden-vector /
+ /// round-trip pair against BuildConfirmationResponse.
+ ///
+ public readonly record struct ConfirmationResponse(
+ ConfirmationType Type,
+ uint ContextId,
+ bool Accepted);
+
+ public static ConfirmationResponse? ParseConfirmationResponse(ReadOnlySpan payload)
+ {
+ if (payload.Length < 12) return null;
+ return new ConfirmationResponse(
+ (ConfirmationType)BinaryPrimitives.ReadUInt32LittleEndian(payload),
+ BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
+ BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)) != 0);
+ }
+
+ // ── Fellowship (Campaign FA slice FA1, 2026-08-11) ──────────────────────
+ //
+ // UNWIRED: these are pure parse functions + typed records only. FA2
+ // registers them against a Runtime-owned RuntimeFellowshipState via
+ // GameEventWiring.WireAll — see docs/research/2026-08-11-fa-acdream-seams.md
+ // §2. Field orders are byte-verified against
+ // docs/research/2026-08-11-fa-fellowship-wire.md (lane B) §3.8-§3.13,
+ // three-way agreed with ACE + holtburger there.
+
+ ///
+ /// One retail Fellow record — Fellow::Pack @0x005B9A10 /
+ /// Fellow::UnPack @0x005B9AD0 (lane B §3.8). The member GUID is
+ /// NOT part of this struct on the wire (it is the enclosing hash
+ /// table's key in 0x02BE, or a separate leading field in
+ /// 0x02C0) — carried here as for convenience
+ /// since both callers already have it in hand when they construct this
+ /// record. is the RAW wire u32, never a bool —
+ /// ACE encodes it two mutually-inconsistent ways (0x10 in full
+ /// updates, <<1 in incremental updates), so the only safe
+ /// read is ShareLoot != 0, never == 1 (lane B §4.1, D5).
+ ///
+ public readonly record struct FellowMember(
+ uint Guid,
+ uint CpCache,
+ uint LumCache,
+ uint Level,
+ uint MaxHealth,
+ uint MaxStamina,
+ uint MaxMana,
+ uint CurrentHealth,
+ uint CurrentStamina,
+ uint CurrentMana,
+ uint ShareLoot,
+ string Name);
+
+ /// One entry of the _fellows_departed hash table (lane B §2.11/§3.9 field 8).
+ public readonly record struct FellowshipDepartedMember(uint Guid, int DepartedTimestamp);
+
+ ///
+ /// 0x02BE FellowshipFullUpdate — Fellowship::Pack/
+ /// UnPack @0x005B94F0 (lane B §3.9). Field 9 (the lock-name
+ /// table) is intentionally NOT parsed here: retail's own
+ /// Fellowship::UnPack stops reading after field 8 and never
+ /// consumes it (lane B §2.7 caution), and lane B's own U3 flags
+ /// FellowshipLockData's three unknown u32s AND a possible ACE/
+ /// pcap struct-width mismatch as unverified — the BN-fold rule says
+ /// stop and report rather than guess a shape for an unverified table,
+ /// so this parser is deliberately silent on it rather than risk
+ /// desynchronizing on a table nothing in this campaign needs yet.
+ ///
+ public readonly record struct FellowshipFullUpdate(
+ IReadOnlyList Members,
+ string Name,
+ uint LeaderGuid,
+ bool ShareXp,
+ bool EvenXpSplit,
+ bool OpenFellow,
+ bool Locked,
+ IReadOnlyList Departed);
+
+ public static FellowshipFullUpdate? ParseFellowshipFullUpdate(ReadOnlySpan payload)
+ {
+ try
+ {
+ int pos = 0;
+ ushort memberCount = FellowshipReadU16(payload, ref pos);
+ _ = FellowshipReadU16(payload, ref pos); // numBuckets — server-chosen (16), not consulted (lane B §3.9)
+ var members = new List(memberCount);
+ for (int i = 0; i < memberCount; i++)
+ {
+ uint guid = FellowshipReadU32(payload, ref pos);
+ members.Add(ReadFellow(payload, ref pos, guid));
+ }
+
+ string name = ReadString16L(payload, ref pos);
+ uint leaderGuid = FellowshipReadU32(payload, ref pos);
+ bool shareXp = FellowshipReadU32(payload, ref pos) != 0u;
+ bool evenXpSplit = FellowshipReadU32(payload, ref pos) != 0u;
+ bool openFellow = FellowshipReadU32(payload, ref pos) != 0u;
+ bool locked = FellowshipReadU32(payload, ref pos) != 0u;
+
+ ushort departedCount = FellowshipReadU16(payload, ref pos);
+ _ = FellowshipReadU16(payload, ref pos); // numBuckets (32) — not consulted
+ var departed = new List(departedCount);
+ for (int i = 0; i < departedCount; i++)
+ {
+ uint guid = FellowshipReadU32(payload, ref pos);
+ int timestamp = unchecked((int)FellowshipReadU32(payload, ref pos));
+ departed.Add(new FellowshipDepartedMember(guid, timestamp));
+ }
+
+ return new FellowshipFullUpdate(
+ members, name, leaderGuid, shareXp, evenXpSplit, openFellow, locked, departed);
+ }
+ catch (FormatException) { return null; }
+ }
+
+ ///
+ /// 0x02C0 FellowshipUpdateFellow —
+ /// DispatchUI_UpdateFellow @0x006A6700 (lane B §3.10):
+ /// [u32 guid][Fellow][u32 updateType], guid FIRST (Chorizite's
+ /// generated shape omits the guid — resolved retail-wins per lane B's
+ /// "Reference disagreement, resolved" note). updateType: 0
+ /// Undef, 1 Full, 2 UpdateStats (ACE never sends this), 3 UpdateVitals.
+ ///
+ public readonly record struct FellowshipUpdateFellow(
+ uint MemberGuid,
+ FellowMember Member,
+ uint UpdateType);
+
+ public static FellowshipUpdateFellow? ParseFellowshipUpdateFellow(ReadOnlySpan payload)
+ {
+ try
+ {
+ int pos = 0;
+ uint guid = FellowshipReadU32(payload, ref pos);
+ FellowMember member = ReadFellow(payload, ref pos, guid);
+ uint updateType = FellowshipReadU32(payload, ref pos);
+ return new FellowshipUpdateFellow(guid, member, updateType);
+ }
+ catch (FormatException) { return null; }
+ }
+
+ ///
+ /// 0x00A3 FellowshipQuit, the S→C direction —
+ /// DispatchUI_Quit @0x006A5F5F (lane B §3.12):
+ /// [u32 quitterGuid]. Sent both to the quitter and to every
+ /// remaining member; the recipient distinguishes by comparing the guid
+ /// to its own. (The C→S direction of the same opcode is
+ /// SocialActions.BuildFellowshipQuit — a different payload
+ /// shape entirely, disambiguated here by record name.)
+ ///
+ public readonly record struct FellowshipQuitNotice(uint QuitterGuid);
+
+ public static FellowshipQuitNotice? ParseFellowshipQuit(ReadOnlySpan payload)
+ {
+ if (payload.Length < 4) return null;
+ return new FellowshipQuitNotice(BinaryPrimitives.ReadUInt32LittleEndian(payload));
+ }
+
+ ///
+ /// 0x00A4 FellowshipDismiss, the S→C direction —
+ /// DispatchUI_Dismiss @0x006A5EC9 (lane B §3.13):
+ /// [u32 dismissedGuid]. ACE's own comment notes the same numeric
+ /// opcode value is used in both directions with different envelopes.
+ ///
+ public readonly record struct FellowshipDismissNotice(uint DismissedGuid);
+
+ public static FellowshipDismissNotice? ParseFellowshipDismiss(ReadOnlySpan payload)
+ {
+ if (payload.Length < 4) return null;
+ return new FellowshipDismissNotice(BinaryPrimitives.ReadUInt32LittleEndian(payload));
+ }
+
+ ///
+ /// 0x02BF FellowshipDisband — empty body
+ /// (DispatchUI_Disband @0x006A5E80 reads only the opcode; ACE
+ /// writes no body — lane B §3.11).
+ ///
+ public static bool ParseFellowshipDisband(ReadOnlySpan payload) => payload.Length == 0;
+
+ ///
+ /// 0x01C9 FellowshipFellowUpdateDone — dead in the Sept-2013
+ /// client: both this and
+ /// COMDAT-fold onto the identical no-op body (lane B §2.7). The
+ /// dispatcher DOES read a trailing u32 payload even though nothing
+ /// consumes it, and ACE currently writes a zero-length body — so this
+ /// parser must succeed on BOTH an empty payload and a trailing u32,
+ /// and never fail the message (parse-and-ignore).
+ /// is surfaced only for diagnostics.
+ ///
+ public readonly record struct FellowshipFellowUpdateDone(uint? RawValue);
+
+ public static FellowshipFellowUpdateDone ParseFellowshipFellowUpdateDone(ReadOnlySpan payload)
+ => new(payload.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(payload) : null);
+
+ ///
+ /// 0x01CA FellowshipFellowStatsDone — same dead-COMDAT-fold
+ /// class as (lane B §2.7);
+ /// kept as a distinct record type to match the distinct
+ /// id even though the shape is identical.
+ ///
+ public readonly record struct FellowshipFellowStatsDone(uint? RawValue);
+
+ public static FellowshipFellowStatsDone ParseFellowshipFellowStatsDone(ReadOnlySpan payload)
+ => new(payload.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(payload) : null);
+
+ private static FellowMember ReadFellow(ReadOnlySpan payload, ref int pos, uint guid)
+ {
+ uint cpCache = FellowshipReadU32(payload, ref pos);
+ uint lumCache = FellowshipReadU32(payload, ref pos);
+ uint level = FellowshipReadU32(payload, ref pos);
+ uint maxHealth = FellowshipReadU32(payload, ref pos);
+ uint maxStamina = FellowshipReadU32(payload, ref pos);
+ uint maxMana = FellowshipReadU32(payload, ref pos);
+ uint currentHealth = FellowshipReadU32(payload, ref pos);
+ uint currentStamina = FellowshipReadU32(payload, ref pos);
+ uint currentMana = FellowshipReadU32(payload, ref pos);
+ uint shareLoot = FellowshipReadU32(payload, ref pos); // RAW — D5/lane B §4.1: != 0, NEVER == 1
+ string name = ReadString16L(payload, ref pos);
+ return new FellowMember(
+ guid, cpCache, lumCache, level, maxHealth, maxStamina, maxMana,
+ currentHealth, currentStamina, currentMana, shareLoot, name);
+ }
+
+ private static uint FellowshipReadU32(ReadOnlySpan source, ref int pos)
+ {
+ if (source.Length - pos < 4) throw new FormatException("truncated u32");
+ uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(pos));
+ pos += 4;
+ return value;
+ }
+
+ private static ushort FellowshipReadU16(ReadOnlySpan source, ref int pos)
+ {
+ if (source.Length - pos < 2) throw new FormatException("truncated u16");
+ ushort value = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(pos));
+ pos += 2;
+ return value;
+ }
+
+ // ── Allegiance small events (Campaign FA slice FA1, 2026-08-11) ─────────
+ //
+ // UNWIRED (FA2 connects them). The heavyweight 0x0020 AllegianceUpdate
+ // (profile push) and its shared-parser reuse of
+ // ClientCommandResponses.ParseAllegianceInfoResponse live in
+ // ClientCommandResponses.cs (lane C §7.2's explicit reuse verdict), not
+ // here — this section covers the small fixed-shape allegiance events.
+
+ ///
+ /// 0x027A AllegianceLoginNotification —
+ /// DispatchUI_AllegianceLoginNotificationEvent @0x006A6920 (lane
+ /// C §2 row 8, §4.5): [u32 characterGuid][u32 isLoggedIn]. Retail
+ /// prints nothing if the guid is not already in the cached profile
+ /// (lane C §1.6) — that filtering is a display-time concern for the
+ /// consumer, not this parser.
+ ///
+ public readonly record struct AllegianceLoginNotification(uint CharacterGuid, bool IsLoggedIn);
+
+ public static AllegianceLoginNotification? ParseAllegianceLoginNotification(ReadOnlySpan payload)
+ {
+ if (payload.Length < 8) return null;
+ return new AllegianceLoginNotification(
+ BinaryPrimitives.ReadUInt32LittleEndian(payload),
+ BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)) != 0u);
+ }
+
+ ///
+ /// 0x01C8 AllegianceUpdateDone — the panel busy-cursor
+ /// terminator (lane C §1.2, §2 row 6): a single WeenieError
+ /// u32 (0 on success).
+ ///
+ public static uint? ParseAllegianceUpdateDone(ReadOnlySpan payload)
+ {
+ if (payload.Length < 4) return null;
+ return BinaryPrimitives.ReadUInt32LittleEndian(payload);
+ }
+
+ ///
+ /// 0x0003 AllegianceUpdateAborted — declared by retail
+ /// (DispatchUI_AllegianceUpdateAborted @0x006A6950) but never
+ /// actually sent by ACE (lane C §2 row 7, §5.4) — a single
+ /// WeenieError u32, parsed for completeness/forward-compat.
+ ///
+ public static uint? ParseAllegianceUpdateAborted(ReadOnlySpan payload)
+ {
+ if (payload.Length < 4) return null;
+ return BinaryPrimitives.ReadUInt32LittleEndian(payload);
+ }
+
// ── House ────────────────────────────────────────────────────────────────
///
diff --git a/tests/AcDream.Core.Net.Tests/Messages/AllegianceProfileVersionGateTests.cs b/tests/AcDream.Core.Net.Tests/Messages/AllegianceProfileVersionGateTests.cs
new file mode 100644
index 00000000..6c4cfba1
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Messages/AllegianceProfileVersionGateTests.cs
@@ -0,0 +1,522 @@
+using System.Collections.Generic;
+using AcDream.Core.Net.Messages;
+using Xunit;
+
+namespace AcDream.Core.Net.Tests.Messages;
+
+///
+/// Campaign FA slice FA1 (2026-08-11): conformance tests for the ELEVEN
+/// AllegianceHierarchy::UnPack version gates
+/// (docs/research/2026-08-11-fa-allegiance-wire.md, lane C, §4.2) and the
+/// §4.4 tree-assembly rules (orphan treeParent discards the whole
+/// message; sibling order reverses on assembly), plus the new
+/// ParseAllegianceUpdate (0x0020) sharing the extended
+/// ParseAllegianceInfoResponse (0x027C) profile reader.
+///
+///
+/// Every version-gate test builds TWO fixtures — oldVersion = N-1
+/// (field OFF) and oldVersion = N (field ON) — for each of the
+/// eleven AllegianceVersion thresholds (1 through 11), proving both
+/// that the monarch record downstream still parses correctly (the
+/// structural proof: a wrong gate offset garbles every field after it)
+/// and, where a field is surfaced on the public record, that its
+/// presence/default flips exactly at the threshold.
+///
+///
+public sealed class AllegianceProfileVersionGateTests
+{
+ private const uint MonarchGuid = 0x50000001u;
+
+ private static readonly List<(uint characterId, uint parentGuid, bool loggedIn, string name)> OneMonarch =
+ new() { (MonarchGuid, 0u, true, "Monarch") };
+
+ ///
+ /// Builds one AllegianceProfile wire body (0x027C/0x0020 shape,
+ /// leading discriminator u32 supplied by the caller) at a given
+ /// oldVersion, writing ONLY the fields that version's gates
+ /// admit — mirrors ACE/Source/.../AllegianceHierarchy.cs's own
+ /// writer field order, cross-checked against lane C §4.1-§4.2.
+ ///
+ private static byte[] BuildProfileWire(
+ uint leadingField,
+ ushort oldVersion,
+ List<(uint characterId, uint parentGuid, bool loggedIn, string name)> records,
+ int officerEntries = 0,
+ int officerTitleEntries = 0,
+ string motd = "TheMotd",
+ string motdSetBy = "MotdSetter",
+ uint chatRoomId = 0x77770000u,
+ string allegianceName = "TestAllegiance",
+ uint nameLastSetTime = 0x88880000u,
+ bool isLocked = true,
+ uint approvedVassal = 0x99990000u)
+ {
+ var w = new AceWireWriter()
+ .Write(leadingField)
+ .Write((uint)records.Count) // totalMembers
+ .Write((uint)0) // totalVassals
+ .Write((ushort)records.Count) // recordCount
+ .Write(oldVersion);
+
+ // §4.2 gates 1/2.
+ if (oldVersion >= 6)
+ {
+ w.Write((ushort)officerEntries).Write((ushort)256);
+ for (int i = 0; i < officerEntries; i++)
+ w.Write(0x60000000u + (uint)i).Write((uint)2);
+ }
+ else if (oldVersion >= 1)
+ {
+ w.Write(0xDEADBEEFu); // old single spokesperson id
+ }
+
+ // §4.2 gate 3.
+ if (oldVersion >= 9)
+ {
+ w.Write((uint)officerTitleEntries);
+ for (int i = 0; i < officerTitleEntries; i++)
+ w.WriteString16L($"Title{i}");
+ }
+
+ // §4.2 gate 4 (PoolsAdded block, 4 counters).
+ if (oldVersion >= 2)
+ w.Write((uint)1).Write((uint)2).Write((uint)3).Write((uint)4);
+
+ // §4.2 gate 5 (MotdAdded).
+ if (oldVersion >= 3)
+ w.WriteString16L(motd).WriteString16L(motdSetBy);
+
+ // §4.2 gate 6 (ChatRoomIDAdded).
+ if (oldVersion >= 4)
+ w.Write(chatRoomId);
+
+ // §4.2 gate 7 (Bindstones) — 32-byte Position.
+ if (oldVersion >= 7)
+ {
+ w.Write((uint)0)
+ .Write(1f).Write(2f).Write(3f)
+ .Write(1f).Write(0f).Write(0f).Write(0f);
+ }
+
+ // §4.2 gate 8 (AllegianceName).
+ if (oldVersion >= 8)
+ w.WriteString16L(allegianceName).Write(nameLastSetTime);
+
+ // §4.2 gate 9 (LockedState).
+ if (oldVersion >= 10)
+ w.Write(isLocked ? 1u : 0u);
+
+ // §4.2 gate 10 (ApprovedVassal).
+ if (oldVersion >= 11)
+ w.Write(approvedVassal);
+
+ for (int i = 0; i < records.Count; i++)
+ {
+ (uint characterId, uint parentGuid, bool loggedIn, string name) = records[i];
+ if (i > 0)
+ w.Write(parentGuid);
+
+ uint bitfield = 0x4u | 0x8u; // HasAllegianceAge | HasPackedLevel
+ if (loggedIn) bitfield |= 0x1u;
+
+ w.Write(characterId)
+ .Write((uint)0).Write((uint)0) // cpCached, cpTithed
+ .Write(bitfield)
+ .Write((byte)0).Write((byte)0) // gender, heritage
+ .Write((ushort)1) // rank
+ .Write((uint)5) // level
+ .Write((ushort)0).Write((ushort)0) // loyalty, leadership
+ .Write((uint)0).Write((uint)0) // timeOnline, allegianceAge
+ .WriteString16L(name);
+ }
+
+ return w.ToArray();
+ }
+
+ private static ClientCommandResponses.AllegianceInfoResponse ParseAt(
+ ushort oldVersion,
+ List<(uint, uint, bool, string)> records,
+ int officerEntries = 0,
+ int officerTitleEntries = 0)
+ {
+ byte[] wire = BuildProfileWire(
+ MonarchGuid, oldVersion, records,
+ officerEntries: officerEntries, officerTitleEntries: officerTitleEntries);
+ var parsed = ClientCommandResponses.ParseAllegianceInfoResponse(wire);
+ Assert.NotNull(parsed);
+ return parsed!.Value;
+ }
+
+ // ── Gate 1/2 boundary: v0 (neither) → v1 (old spokesperson 4-byte skip) ──
+
+ [Fact]
+ public void VersionGate_0to1_OldSpokespersonSkipTurnsOn()
+ {
+ var v0 = ParseAt(0, OneMonarch);
+ var v1 = ParseAt(1, OneMonarch);
+
+ Assert.Equal((ushort)0, v0.OldVersion);
+ Assert.Equal((ushort)1, v1.OldVersion);
+ Assert.Equal("Monarch", v0.Monarch!.Value.Name);
+ Assert.Equal("Monarch", v1.Monarch!.Value.Name);
+ Assert.Equal(MonarchGuid, v0.Monarch!.Value.CharacterId);
+ Assert.Equal(MonarchGuid, v1.Monarch!.Value.CharacterId);
+ }
+
+ // ── Gate: v1 → v2 (PoolsAdded — 4 broadcast counters) ────────────────────
+
+ [Fact]
+ public void VersionGate_1to2_BroadcastCountersTurnOn()
+ {
+ var v1 = ParseAt(1, OneMonarch);
+ var v2 = ParseAt(2, OneMonarch);
+
+ // Not surfaced on the public record (lane C §5.1: ACE never
+ // assigns them) — the structural proof (monarch survives intact)
+ // is what confirms the gate consumed exactly the right bytes.
+ Assert.Equal("Monarch", v1.Monarch!.Value.Name);
+ Assert.Equal("Monarch", v2.Monarch!.Value.Name);
+ }
+
+ // ── Gate 5: v2 → v3 (MotdAdded) ───────────────────────────────────────
+
+ [Fact]
+ public void VersionGate_2to3_MotdTurnsOn()
+ {
+ var v2 = ParseAt(2, OneMonarch);
+ var v3 = ParseAt(3, OneMonarch);
+
+ Assert.Equal("", v2.Motd);
+ Assert.Equal("", v2.MotdSetBy);
+ Assert.Equal("TheMotd", v3.Motd);
+ Assert.Equal("MotdSetter", v3.MotdSetBy);
+ Assert.Equal("Monarch", v3.Monarch!.Value.Name);
+ }
+
+ // ── Gate 6: v3 → v4 (ChatRoomIDAdded) ─────────────────────────────────
+
+ [Fact]
+ public void VersionGate_3to4_ChatRoomIdTurnsOn()
+ {
+ var v3 = ParseAt(3, OneMonarch);
+ var v4 = ParseAt(4, OneMonarch);
+
+ Assert.Equal(0u, v3.ChatRoomId);
+ Assert.Equal(0x77770000u, v4.ChatRoomId);
+ Assert.Equal("Monarch", v4.Monarch!.Value.Name);
+ }
+
+ // ── v4 → v5: BannedCharactersAdded (version 5) gates NOTHING in UnPack ──
+ // (lane C §4.2 note) — this is the negative proof: v5 must parse
+ // structurally IDENTICALLY to v4 since no new field exists between them.
+
+ [Fact]
+ public void VersionGate_4to5_BannedCharactersAddedGatesNothing()
+ {
+ var v4 = ParseAt(4, OneMonarch);
+ var v5 = ParseAt(5, OneMonarch);
+
+ Assert.Equal(v4.ChatRoomId, v5.ChatRoomId);
+ Assert.Equal(v4.Motd, v5.Motd);
+ Assert.Equal("Monarch", v5.Monarch!.Value.Name);
+ }
+
+ // ── Gate 1 (the real one): v5 → v6 (MultipleAllegianceOfficersAdded) ────
+ // The officers PHashTable REPLACES the 4-byte spokesperson skip.
+ // officerEntries=2 at v6 proves the table's non-trivial byte count is
+ // consumed correctly (a wrong gate would garble the monarch that follows).
+
+ [Fact]
+ public void VersionGate_5to6_OfficersTableReplacesSpokespersonSkip()
+ {
+ var v5 = ParseAt(5, OneMonarch);
+ var v6 = ParseAt(6, OneMonarch, officerEntries: 2);
+
+ Assert.Equal((ushort)5, v5.OldVersion);
+ Assert.Equal((ushort)6, v6.OldVersion);
+ Assert.Equal("Monarch", v5.Monarch!.Value.Name);
+ Assert.Equal("Monarch", v6.Monarch!.Value.Name);
+ }
+
+ // ── Gate: v6 → v7 (Bindstones — 32-byte Position) ────────────────────────
+
+ [Fact]
+ public void VersionGate_6to7_BindPointTurnsOn()
+ {
+ var v6 = ParseAt(6, OneMonarch);
+ var v7 = ParseAt(7, OneMonarch);
+
+ // Not surfaced on the public record (deliberate FA1 scope
+ // decision — see AllegianceProfileBody's class doc); the
+ // structural proof is the monarch surviving both 0 and 32 extra
+ // bytes correctly.
+ Assert.Equal("Monarch", v6.Monarch!.Value.Name);
+ Assert.Equal("Monarch", v7.Monarch!.Value.Name);
+ }
+
+ // ── Gate 8: v7 → v8 (AllegianceName + NameLastSetTime) ───────────────────
+
+ [Fact]
+ public void VersionGate_7to8_AllegianceNameTurnsOn()
+ {
+ var v7 = ParseAt(7, OneMonarch);
+ var v8 = ParseAt(8, OneMonarch);
+
+ Assert.Equal("", v7.AllegianceName);
+ Assert.Equal(0u, v7.NameLastSetTime);
+ Assert.Equal("TestAllegiance", v8.AllegianceName);
+ Assert.Equal(0x88880000u, v8.NameLastSetTime);
+ Assert.Equal("Monarch", v8.Monarch!.Value.Name);
+ }
+
+ // ── Gate 3: v8 → v9 (OfficersTitlesAdded) ────────────────────────────────
+
+ [Fact]
+ public void VersionGate_8to9_OfficerTitlesTurnOn()
+ {
+ var v8 = ParseAt(8, OneMonarch);
+ var v9 = ParseAt(9, OneMonarch, officerTitleEntries: 2);
+
+ Assert.Equal("Monarch", v8.Monarch!.Value.Name);
+ Assert.Equal("Monarch", v9.Monarch!.Value.Name);
+ }
+
+ // ── Gate 9: v9 → v10 (LockedState) ───────────────────────────────────────
+
+ [Fact]
+ public void VersionGate_9to10_IsLockedTurnsOn()
+ {
+ var v9 = ParseAt(9, OneMonarch);
+ var v10 = ParseAt(10, OneMonarch);
+
+ Assert.False(v9.IsLocked);
+ Assert.True(v10.IsLocked);
+ Assert.Equal("Monarch", v10.Monarch!.Value.Name);
+ }
+
+ // ── Gate 10: v10 → v11 (ApprovedVassal) ──────────────────────────────────
+
+ [Fact]
+ public void VersionGate_10to11_ApprovedVassalTurnsOn()
+ {
+ var v10 = ParseAt(10, OneMonarch);
+ var v11 = ParseAt(11, OneMonarch);
+
+ Assert.Equal(0u, v10.ApprovedVassal);
+ Assert.Equal(0x99990000u, v11.ApprovedVassal);
+ Assert.Equal("Monarch", v11.Monarch!.Value.Name);
+ }
+
+ // ── §4.4 tree-assembly rules ──────────────────────────────────────────
+
+ [Fact]
+ public void TreeAssembly_OrphanTreeParent_DiscardsWholeMessage()
+ {
+ // AllegianceHierarchy::Add @0x005B6E90: a record whose treeParent
+ // has not already been added returns 0, which makes UnPack return
+ // 0 and the client drop the ENTIRE message.
+ var records = new List<(uint, uint, bool, string)>
+ {
+ (MonarchGuid, 0u, true, "Monarch"),
+ (0x50000005u, 0x5000FFFFu /* never-seen parent */, true, "Orphan"),
+ };
+ byte[] wire = BuildProfileWire(MonarchGuid, 11, records);
+
+ Assert.Null(ClientCommandResponses.ParseAllegianceInfoResponse(wire));
+ }
+
+ [Fact]
+ public void TreeAssembly_SelfParent_DiscardsWholeMessage()
+ {
+ var records = new List<(uint, uint, bool, string)>
+ {
+ (MonarchGuid, 0u, true, "Monarch"),
+ (0x50000006u, 0x50000006u /* itself */, true, "SelfParent"),
+ };
+ byte[] wire = BuildProfileWire(MonarchGuid, 11, records);
+
+ Assert.Null(ClientCommandResponses.ParseAllegianceInfoResponse(wire));
+ }
+
+ [Fact]
+ public void TreeAssembly_DuplicateId_DiscardsWholeMessage()
+ {
+ var records = new List<(uint, uint, bool, string)>
+ {
+ (MonarchGuid, 0u, true, "Monarch"),
+ (0x50000007u, MonarchGuid, true, "First"),
+ (0x50000007u, MonarchGuid, true, "DuplicateAgain"),
+ };
+ byte[] wire = BuildProfileWire(MonarchGuid, 11, records);
+
+ Assert.Null(ClientCommandResponses.ParseAllegianceInfoResponse(wire));
+ }
+
+ [Fact]
+ public void TreeAssembly_ValidChain_ParentBeforeChild_Succeeds()
+ {
+ var records = new List<(uint, uint, bool, string)>
+ {
+ (MonarchGuid, 0u, true, "Monarch"),
+ (0x50000002u, MonarchGuid, true, "Patron"),
+ (0x50000003u, 0x50000002u, true, "Self"),
+ };
+ byte[] wire = BuildProfileWire(MonarchGuid, 11, records);
+
+ var parsed = ClientCommandResponses.ParseAllegianceInfoResponse(wire);
+ Assert.NotNull(parsed);
+ Assert.Equal(2, parsed!.Value.Records.Count);
+ }
+
+ [Fact]
+ public void TreeAssembly_SiblingOrder_ReversesOnAssembly()
+ {
+ // AllegianceHierarchy::Add prepends each new record to its
+ // parent's vassal list ("_peer = parent->_vassal; parent->_vassal
+ // = node"), so GetFirstVassal/GetNextVassal — FindVassals here —
+ // walks in REVERSE wire order: the record parsed LAST under a
+ // given parent renders FIRST (lane C §4.4 point 3).
+ var records = new List<(uint, uint, bool, string)>
+ {
+ (MonarchGuid, 0u, true, "Monarch"),
+ (0x50000010u, MonarchGuid, true, "VassalA"),
+ (0x50000011u, MonarchGuid, true, "VassalB"),
+ (0x50000012u, MonarchGuid, true, "VassalC"),
+ };
+ byte[] wire = BuildProfileWire(MonarchGuid, 11, records);
+
+ var parsed = ClientCommandResponses.ParseAllegianceInfoResponse(wire);
+ Assert.NotNull(parsed);
+
+ var vassalNames = new List();
+ foreach (var v in parsed!.Value.FindVassals(MonarchGuid))
+ vassalNames.Add(v.Name);
+
+ Assert.Equal(new[] { "VassalC", "VassalB", "VassalA" }, vassalNames);
+ }
+
+ // ── Surfaced per-record fields (lane C §7.2's "panel needs" list) ────────
+
+ [Fact]
+ public void ReadAllegianceData_SurfacesRankLevelLoyaltyLeadershipCpGenderHeritage()
+ {
+ var w = new AceWireWriter()
+ .Write(MonarchGuid) // targetGuid
+ .Write((uint)1).Write((uint)0) // totalMembers, totalVassals
+ .Write((ushort)1).Write((ushort)11) // recordCount, oldVersion=11 (newest)
+ .Write((ushort)0).Write((ushort)256) // officers: empty
+ .Write((uint)0) // officerTitles: empty
+ .Write((uint)0).Write((uint)0).Write((uint)0).Write((uint)0) // broadcast counters
+ .WriteString16L("").WriteString16L("") // motd, motdSetBy
+ .Write((uint)0) // chatRoomID
+ .Write((uint)0).Write(0f).Write(0f).Write(0f).Write(1f).Write(0f).Write(0f).Write(0f) // bindPoint
+ .WriteString16L("Alle") // allegianceName
+ .Write((uint)0) // nameLastSetTime
+ .Write((uint)0) // isLocked
+ .Write((uint)0) // approvedVassal
+ // monarch AllegianceData
+ .Write(MonarchGuid)
+ .Write((uint)12345) // cpCached
+ .Write((uint)67890) // cpTithed
+ .Write((uint)(0x4u | 0x8u | 0x1u)) // HasAllegianceAge | HasPackedLevel | LoggedIn
+ .Write((byte)2) // gender
+ .Write((byte)3) // heritage group
+ .Write((ushort)7) // rank
+ .Write((uint)42) // level (HasPackedLevel set)
+ .Write((ushort)200) // loyalty
+ .Write((ushort)150) // leadership
+ .Write((uint)0).Write((uint)0) // timeOnline, allegianceAge
+ .WriteString16L("Monarch");
+
+ var parsed = ClientCommandResponses.ParseAllegianceInfoResponse(w.ToArray());
+
+ Assert.NotNull(parsed);
+ var monarch = parsed!.Value.Monarch!.Value;
+ Assert.Equal((ushort)7, monarch.Rank);
+ Assert.Equal(42u, monarch.Level);
+ Assert.Equal((ushort)200, monarch.Loyalty);
+ Assert.Equal((ushort)150, monarch.Leadership);
+ Assert.Equal(12345u, monarch.CpCached);
+ Assert.Equal(67890u, monarch.CpTithed);
+ Assert.Equal((byte)2, monarch.Gender);
+ Assert.Equal((byte)3, monarch.HeritageGroup);
+ // HasPackedLevel IS set here, so MayPassupExperience follows the
+ // wire bit (0x10, unset in this fixture) rather than the
+ // legacy-compat override.
+ Assert.False(monarch.MayPassupExperience);
+ }
+
+ [Fact]
+ public void ReadAllegianceData_MayPassupExperience_SetWhenHasPackedLevelAbsent()
+ {
+ // Lane C §4.1 point 1: when HasPackedLevel (0x8) is absent, retail
+ // sets MayPassupExperience itself regardless of the wire bit.
+ var w = new AceWireWriter()
+ .Write(MonarchGuid)
+ .Write((uint)1).Write((uint)0)
+ .Write((ushort)1).Write((ushort)11)
+ .Write((ushort)0).Write((ushort)256)
+ .Write((uint)0)
+ .Write((uint)0).Write((uint)0).Write((uint)0).Write((uint)0)
+ .WriteString16L("").WriteString16L("")
+ .Write((uint)0)
+ .Write((uint)0).Write(0f).Write(0f).Write(0f).Write(1f).Write(0f).Write(0f).Write(0f)
+ .WriteString16L("Alle")
+ .Write((uint)0)
+ .Write((uint)0)
+ .Write((uint)0)
+ .Write(MonarchGuid)
+ .Write((uint)0).Write((uint)0)
+ .Write((uint)0x4u) // HasAllegianceAge only — NO HasPackedLevel, NO MayPassupExperience bit
+ .Write((byte)0).Write((byte)0)
+ .Write((ushort)0)
+ // no level field (HasPackedLevel unset)
+ .Write((ushort)0).Write((ushort)0)
+ .Write((uint)0).Write((uint)0)
+ .WriteString16L("Monarch");
+
+ var parsed = ClientCommandResponses.ParseAllegianceInfoResponse(w.ToArray());
+
+ Assert.NotNull(parsed);
+ Assert.True(parsed!.Value.Monarch!.Value.MayPassupExperience);
+ Assert.Equal(0u, parsed.Value.Monarch!.Value.Level); // never read — HasPackedLevel unset
+ }
+
+ // ── 0x0020 AllegianceUpdate shares the same profile reader ─────────────
+
+ [Fact]
+ public void ParseAllegianceUpdate_RankLeading_SharesProfileReaderWithInfoResponse()
+ {
+ var records = new List<(uint, uint, bool, string)>
+ {
+ (MonarchGuid, 0u, true, "Monarch"),
+ (0x50000002u, MonarchGuid, true, "Vassal"),
+ };
+ const uint rank = 3u;
+ byte[] wire = BuildProfileWire(rank, 11, records);
+
+ var update = ClientCommandResponses.ParseAllegianceUpdate(wire);
+
+ Assert.NotNull(update);
+ Assert.Equal(rank, update!.Value.Rank);
+ Assert.Equal("Monarch", update.Value.Monarch!.Value.Name);
+ Assert.Single(update.Value.Records);
+ Assert.Equal("Vassal", update.Value.Records[0].Name);
+ Assert.Equal("TestAllegiance", update.Value.AllegianceName);
+ Assert.True(update.Value.IsLocked);
+ Assert.Equal(0x99990000u, update.Value.ApprovedVassal);
+ }
+
+ [Fact]
+ public void ParseAllegianceUpdate_TreeAssemblyRulesApply_OrphanDiscardsWholeMessage()
+ {
+ var records = new List<(uint, uint, bool, string)>
+ {
+ (MonarchGuid, 0u, true, "Monarch"),
+ (0x50000005u, 0x5000FFFFu, true, "Orphan"),
+ };
+ byte[] wire = BuildProfileWire(3u /* rank */, 11, records);
+
+ Assert.Null(ClientCommandResponses.ParseAllegianceUpdate(wire));
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/AllegianceSmallEventsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/AllegianceSmallEventsTests.cs
new file mode 100644
index 00000000..8daeea90
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Messages/AllegianceSmallEventsTests.cs
@@ -0,0 +1,74 @@
+using AcDream.Core.Net.Messages;
+using Xunit;
+
+namespace AcDream.Core.Net.Tests.Messages;
+
+///
+/// Campaign FA slice FA1 (2026-08-11): the small fixed-shape allegiance
+/// S→C events (everything except 0x0020 AllegianceUpdate and
+/// 0x027C AllegianceInfoResponse, which reuse the extended
+/// ClientCommandResponses profile reader — see
+/// AllegianceProfileVersionGateTests). Field orders per
+/// docs/research/2026-08-11-fa-allegiance-wire.md (lane C) §4.5. Unwired —
+/// FA2 registers these against RuntimeAllegianceState.
+///
+public sealed class AllegianceSmallEventsTests
+{
+ [Fact]
+ public void ParseAllegianceLoginNotification_RoundTrips_LoggedIn()
+ {
+ byte[] wire = new AceWireWriter().Write(0x50000042u).Write((uint)1).ToArray();
+
+ var notice = GameEvents.ParseAllegianceLoginNotification(wire);
+
+ Assert.NotNull(notice);
+ Assert.Equal(0x50000042u, notice.Value.CharacterGuid);
+ Assert.True(notice.Value.IsLoggedIn);
+ }
+
+ [Fact]
+ public void ParseAllegianceLoginNotification_RoundTrips_LoggedOut()
+ {
+ byte[] wire = new AceWireWriter().Write(0x50000042u).Write((uint)0).ToArray();
+
+ var notice = GameEvents.ParseAllegianceLoginNotification(wire);
+
+ Assert.NotNull(notice);
+ Assert.False(notice.Value.IsLoggedIn);
+ }
+
+ [Fact]
+ public void ParseAllegianceLoginNotification_TruncatedPayload_ReturnsNull()
+ {
+ Assert.Null(GameEvents.ParseAllegianceLoginNotification(new byte[4]));
+ }
+
+ [Fact]
+ public void ParseAllegianceUpdateDone_ReadsWeenieError()
+ {
+ byte[] wire = new AceWireWriter().Write(0u).ToArray();
+ Assert.Equal(0u, GameEvents.ParseAllegianceUpdateDone(wire));
+ }
+
+ [Fact]
+ public void ParseAllegianceUpdateDone_NonZeroErrorCode_RoundTrips()
+ {
+ byte[] wire = new AceWireWriter().Write(0x40Bu).ToArray(); // AlreadySworn-style code
+ Assert.Equal(0x40Bu, GameEvents.ParseAllegianceUpdateDone(wire));
+ }
+
+ [Fact]
+ public void ParseAllegianceUpdateAborted_ReadsWeenieError()
+ {
+ // Never actually sent by ACE (lane C §5.4) — parsed for
+ // forward-compat/completeness only.
+ byte[] wire = new AceWireWriter().Write(0x40Cu).ToArray();
+ Assert.Equal(0x40Cu, GameEvents.ParseAllegianceUpdateAborted(wire));
+ }
+
+ [Fact]
+ public void ParseAllegianceUpdateDone_TruncatedPayload_ReturnsNull()
+ {
+ Assert.Null(GameEvents.ParseAllegianceUpdateDone(System.ReadOnlySpan.Empty));
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/ConfirmationTripleTests.cs b/tests/AcDream.Core.Net.Tests/Messages/ConfirmationTripleTests.cs
new file mode 100644
index 00000000..b53acf7a
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Messages/ConfirmationTripleTests.cs
@@ -0,0 +1,80 @@
+using System.Buffers.Binary;
+using AcDream.Core.Net.Messages;
+using Xunit;
+
+namespace AcDream.Core.Net.Tests.Messages;
+
+///
+/// Campaign FA slice FA1 (2026-08-11), D6: completes Core.Net's typed
+/// representation of the shared confirmation triple
+/// (0x0274/0x0275/0x0276). Grepping the tree before
+/// this slice showed 0x0274 ()
+/// and 0x0276 ()
+/// already had typed record + parser pairs; 0x0275 — the CLIENT→
+/// SERVER leg — already had a byte-correct builder
+/// (ClientCommandRequests.BuildConfirmationResponse, lane C §3.3)
+/// but no typed record, no
+/// enum, and no parser. This file pins the completed triple: the new
+/// enum (1 = SwearAllegiance,
+/// 4 = Fellowship — D6), and
+/// round-tripping against the EXISTING builder byte-for-byte.
+///
+public sealed class ConfirmationTripleTests
+{
+ [Fact]
+ public void ConfirmationType_SwearAllegianceIsOne_FellowshipIsFour()
+ {
+ // Handle_Character__ConfirmationRequest @0x005640A0 switch vs ACE
+ // ConfirmationType.cs:5-12 — identical (lane B §3.15).
+ Assert.Equal(1u, (uint)GameEvents.ConfirmationType.SwearAllegiance);
+ Assert.Equal(4u, (uint)GameEvents.ConfirmationType.Fellowship);
+ }
+
+ [Fact]
+ public void ParseConfirmationResponse_RoundTripsAgainstExistingBuilder()
+ {
+ byte[] wire = ClientCommandRequests.BuildConfirmationResponse(
+ sequence: 5,
+ confirmationType: (uint)GameEvents.ConfirmationType.Fellowship,
+ contextId: 0x1234u,
+ accepted: true);
+
+ // Strip the 12-byte envelope/seq/opcode header the same way every
+ // other GameEvents.Parse* function receives its payload (header
+ // already stripped by the dispatcher) — here we strip the GameACTION
+ // header by hand since BuildConfirmationResponse is a C→S builder.
+ var response = GameEvents.ParseConfirmationResponse(wire.AsSpan(12));
+
+ Assert.NotNull(response);
+ Assert.Equal(GameEvents.ConfirmationType.Fellowship, response.Value.Type);
+ Assert.Equal(0x1234u, response.Value.ContextId);
+ Assert.True(response.Value.Accepted);
+ }
+
+ [Fact]
+ public void ParseConfirmationResponse_GoldenByteVector_SwearAllegianceDeclined()
+ {
+ // Hand-computed from CM_Character::Event_ConfirmationResponse
+ // @0x006A1210 (lane B §3.15 / lane C §3.3):
+ // [u32 confirmType][u32 context][u32 accepted].
+ byte[] payload =
+ [
+ 0x01, 0x00, 0x00, 0x00, // confirmType = 1 (SwearAllegiance)
+ 0x99, 0x00, 0x00, 0x00, // context = 0x99
+ 0x00, 0x00, 0x00, 0x00, // accepted = 0 (declined)
+ ];
+
+ var response = GameEvents.ParseConfirmationResponse(payload);
+
+ Assert.NotNull(response);
+ Assert.Equal(GameEvents.ConfirmationType.SwearAllegiance, response.Value.Type);
+ Assert.Equal(0x99u, response.Value.ContextId);
+ Assert.False(response.Value.Accepted);
+ }
+
+ [Fact]
+ public void ParseConfirmationResponse_TruncatedPayload_ReturnsNull()
+ {
+ Assert.Null(GameEvents.ParseConfirmationResponse(new byte[8]));
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/FellowshipEventsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/FellowshipEventsTests.cs
new file mode 100644
index 00000000..88145e61
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Messages/FellowshipEventsTests.cs
@@ -0,0 +1,234 @@
+using System;
+using AcDream.Core.Net.Messages;
+using Xunit;
+
+namespace AcDream.Core.Net.Tests.Messages;
+
+///
+/// Campaign FA slice FA1 (2026-08-11): golden-vector round-trip tests for
+/// the fellowship S→C parsers added to . Fixtures
+/// are built with (the ACE-mirror writer) so a
+/// pass proves agreement with the server's own algorithm, not just with
+/// itself. Field orders per
+/// docs/research/2026-08-11-fa-fellowship-wire.md (lane B) §3.8-§3.13.
+/// These parsers are UNWIRED — FA2 registers them against
+/// RuntimeFellowshipState.
+///
+public sealed class FellowshipEventsTests
+{
+ // ── 0x02BE FellowshipFullUpdate ──────────────────────────────────────
+
+ [Fact]
+ public void ParseFellowshipFullUpdate_RoundTrips_TwoMembersOneDeparted()
+ {
+ byte[] wire = new AceWireWriter()
+ .Write((ushort)2) // memberCount
+ .Write((ushort)16) // numBuckets — server-chosen, not consulted
+ // member 1
+ .Write(0x50000001u)
+ .Write((uint)100) // cpCache
+ .Write((uint)50) // lumCache
+ .Write((uint)20) // level
+ .Write((uint)100) // maxHealth
+ .Write((uint)100) // maxStamina
+ .Write((uint)100) // maxMana
+ .Write((uint)80) // currentHealth
+ .Write((uint)90) // currentStamina
+ .Write((uint)70) // currentMana
+ .Write((uint)0x10) // shareLoot — ACE's full-update "shares" sentinel
+ .WriteString16L("Leader")
+ // member 2
+ .Write(0x50000002u)
+ .Write((uint)0).Write((uint)0).Write((uint)15)
+ .Write((uint)90).Write((uint)90).Write((uint)90)
+ .Write((uint)90).Write((uint)90).Write((uint)90)
+ .Write((uint)0)
+ .WriteString16L("Second")
+ // fellowship-level fields
+ .WriteString16L("TestFellowship")
+ .Write(0x50000001u) // leaderGuid
+ .Write((uint)1) // shareXp
+ .Write((uint)1) // evenXpSplit
+ .Write((uint)0) // openFellow
+ .Write((uint)0) // locked
+ .Write((ushort)1) // departedCount
+ .Write((ushort)32) // numBuckets
+ .Write(0x50000099u)
+ .Write(1700000000)
+ .ToArray();
+
+ var update = GameEvents.ParseFellowshipFullUpdate(wire);
+
+ Assert.NotNull(update);
+ Assert.Equal(2, update.Value.Members.Count);
+ Assert.Equal(0x50000001u, update.Value.Members[0].Guid);
+ Assert.Equal("Leader", update.Value.Members[0].Name);
+ Assert.Equal(100u, update.Value.Members[0].CpCache);
+ Assert.Equal(50u, update.Value.Members[0].LumCache);
+ Assert.Equal(20u, update.Value.Members[0].Level);
+ Assert.Equal(80u, update.Value.Members[0].CurrentHealth);
+ Assert.Equal(0x10u, update.Value.Members[0].ShareLoot);
+ Assert.Equal("Second", update.Value.Members[1].Name);
+ Assert.Equal("TestFellowship", update.Value.Name);
+ Assert.Equal(0x50000001u, update.Value.LeaderGuid);
+ Assert.True(update.Value.ShareXp);
+ Assert.True(update.Value.EvenXpSplit);
+ Assert.False(update.Value.OpenFellow);
+ Assert.False(update.Value.Locked);
+ Assert.Single(update.Value.Departed);
+ Assert.Equal(0x50000099u, update.Value.Departed[0].Guid);
+ Assert.Equal(1700000000, update.Value.Departed[0].DepartedTimestamp);
+ }
+
+ // D5: shareLoot must be a raw uint, `!= 0` means "shares" — NEVER a
+ // ReadBool()-style `== 1` comparison. ACE's incremental-update
+ // encoding is `Convert.ToUInt32(shareLoot) << 1` (0 or 2), which a
+ // `== 1` reader would silently read as "never shares" (lane B §4.1).
+ [Fact]
+ public void ParseFellowshipFullUpdate_ShareLootIsRawNotBool_D5()
+ {
+ byte[] wire = BuildSingleMemberFullUpdate(shareLoot: 2u);
+
+ var update = GameEvents.ParseFellowshipFullUpdate(wire);
+
+ Assert.NotNull(update);
+ Assert.Equal(2u, update.Value.Members[0].ShareLoot);
+ Assert.NotEqual(1u, update.Value.Members[0].ShareLoot);
+ }
+
+ [Fact]
+ public void ParseFellowshipFullUpdate_NoMembersNoDeparted_ParsesEmpty()
+ {
+ byte[] wire = new AceWireWriter()
+ .Write((ushort)0).Write((ushort)16)
+ .WriteString16L("Empty")
+ .Write((uint)0).Write((uint)0).Write((uint)0).Write((uint)0).Write((uint)0)
+ .Write((ushort)0).Write((ushort)32)
+ .ToArray();
+
+ var update = GameEvents.ParseFellowshipFullUpdate(wire);
+
+ Assert.NotNull(update);
+ Assert.Empty(update.Value.Members);
+ Assert.Empty(update.Value.Departed);
+ Assert.Equal("Empty", update.Value.Name);
+ }
+
+ [Fact]
+ public void ParseFellowshipFullUpdate_TruncatedPayload_ReturnsNull()
+ {
+ byte[] wire = new AceWireWriter().Write((ushort)1).ToArray(); // missing everything else
+ Assert.Null(GameEvents.ParseFellowshipFullUpdate(wire));
+ }
+
+ private static byte[] BuildSingleMemberFullUpdate(uint shareLoot)
+ {
+ return new AceWireWriter()
+ .Write((ushort)1).Write((ushort)16)
+ .Write(0x50000001u)
+ .Write((uint)0).Write((uint)0).Write((uint)1)
+ .Write((uint)100).Write((uint)100).Write((uint)100)
+ .Write((uint)100).Write((uint)100).Write((uint)100)
+ .Write(shareLoot)
+ .WriteString16L("Solo")
+ .WriteString16L("Solo Fellowship")
+ .Write(0x50000001u)
+ .Write((uint)0).Write((uint)0).Write((uint)0).Write((uint)0)
+ .Write((ushort)0).Write((ushort)32)
+ .ToArray();
+ }
+
+ // ── 0x02C0 FellowshipUpdateFellow ────────────────────────────────────
+
+ // Lane B §3.10: "Reference disagreement, resolved" — Chorizite's
+ // generated Fellowship_UpdateFellow is missing the leading guid;
+ // retail + ACE + holtburger all put the guid first. Retail wins.
+ [Fact]
+ public void ParseFellowshipUpdateFellow_GuidFirst_RoundTrips()
+ {
+ byte[] wire = new AceWireWriter()
+ .Write(0x50000005u) // guid FIRST
+ .Write((uint)10).Write((uint)5).Write((uint)3)
+ .Write((uint)100).Write((uint)80).Write((uint)60)
+ .Write((uint)90).Write((uint)70).Write((uint)50)
+ .Write((uint)0)
+ .WriteString16L("Vitals")
+ .Write((uint)3) // updateType = 3 UpdateVitals
+ .ToArray();
+
+ var update = GameEvents.ParseFellowshipUpdateFellow(wire);
+
+ Assert.NotNull(update);
+ Assert.Equal(0x50000005u, update.Value.MemberGuid);
+ Assert.Equal(0x50000005u, update.Value.Member.Guid);
+ Assert.Equal("Vitals", update.Value.Member.Name);
+ Assert.Equal(90u, update.Value.Member.CurrentHealth);
+ Assert.Equal(3u, update.Value.UpdateType);
+ }
+
+ // ── 0x00A3/0x00A4 S→C ─────────────────────────────────────────────────
+
+ [Fact]
+ public void ParseFellowshipQuit_ReadsQuitterGuid()
+ {
+ byte[] wire = new AceWireWriter().Write(0x50000009u).ToArray();
+ var notice = GameEvents.ParseFellowshipQuit(wire);
+ Assert.NotNull(notice);
+ Assert.Equal(0x50000009u, notice.Value.QuitterGuid);
+ }
+
+ [Fact]
+ public void ParseFellowshipDismiss_ReadsDismissedGuid()
+ {
+ byte[] wire = new AceWireWriter().Write(0x5000000Au).ToArray();
+ var notice = GameEvents.ParseFellowshipDismiss(wire);
+ Assert.NotNull(notice);
+ Assert.Equal(0x5000000Au, notice.Value.DismissedGuid);
+ }
+
+ // ── 0x02BF FellowshipDisband ──────────────────────────────────────────
+
+ [Fact]
+ public void ParseFellowshipDisband_EmptyBody_ReturnsTrue()
+ {
+ Assert.True(GameEvents.ParseFellowshipDisband(ReadOnlySpan.Empty));
+ }
+
+ [Fact]
+ public void ParseFellowshipDisband_NonEmptyBody_ReturnsFalse()
+ {
+ Assert.False(GameEvents.ParseFellowshipDisband(new byte[] { 1 }));
+ }
+
+ // ── 0x01C9/0x01CA dead events — parse-and-ignore, must never fail ──────
+
+ [Fact]
+ public void ParseFellowshipFellowUpdateDone_EmptyPayload_ToleratedNullRaw()
+ {
+ var done = GameEvents.ParseFellowshipFellowUpdateDone(ReadOnlySpan.Empty);
+ Assert.Null(done.RawValue);
+ }
+
+ [Fact]
+ public void ParseFellowshipFellowUpdateDone_TrailingU32_CapturesRawValue()
+ {
+ byte[] wire = new AceWireWriter().Write(42u).ToArray();
+ var done = GameEvents.ParseFellowshipFellowUpdateDone(wire);
+ Assert.Equal(42u, done.RawValue);
+ }
+
+ [Fact]
+ public void ParseFellowshipFellowStatsDone_EmptyPayload_ToleratedNullRaw()
+ {
+ var done = GameEvents.ParseFellowshipFellowStatsDone(ReadOnlySpan.Empty);
+ Assert.Null(done.RawValue);
+ }
+
+ [Fact]
+ public void ParseFellowshipFellowStatsDone_TrailingU32_CapturesRawValue()
+ {
+ byte[] wire = new AceWireWriter().Write(7u).ToArray();
+ var done = GameEvents.ParseFellowshipFellowStatsDone(wire);
+ Assert.Equal(7u, done.RawValue);
+ }
+}