feat(net): FA1 -- S->C parsers for fellowship/allegiance, confirmation triple, allegiance version gates

Campaign FA slice FA1: pure parse functions + typed records only,
UNWIRED (FA2 registers them against the new RuntimeFellowshipState/
RuntimeAllegianceState owners -- see docs/research/2026-08-11-fa-acdream-seams.md
§2).

Fellowship family (GameEvents.cs), field orders from lane B §3.8-§3.13,
guid-first on 0x02C0 per the resolved Chorizite disagreement:
FellowshipFullUpdate (0x02BE), FellowshipUpdateFellow (0x02C0),
FellowshipQuitNotice/FellowshipDismissNotice (S->C 0x00A3/0x00A4),
FellowshipDisband (0x02BF, empty body), and the dead
FellowshipFellowUpdateDone/FellowshipFellowStatsDone (0x01C9/0x01CA,
parse-and-ignore, must never fail per lane B §2.7). ShareLoot is modeled
as a raw uint (D5) -- ACE encodes it two incompatible ways (0x10 in full
updates, <<1 incremental), so `!= 0` is the only safe read, never `== 1`.

Confirmation triple (D6): grepping the tree showed 0x0274/0x0276 already
had typed parsers in Core.Net; 0x0275 (client-authored) already had a
byte-correct builder but no typed representation. Added the
ConfirmationType enum (1 SwearAllegiance, 4 Fellowship, matching retail's
Handle_Character__ConfirmationRequest switch and ACE's enum verbatim) and
ParseConfirmationResponse, completing Core.Net's typed coverage of all
three legs and round-tripping against the existing
ClientCommandRequests.BuildConfirmationResponse byte-for-byte.

Allegiance small events (GameEvents.cs): AllegianceLoginNotification
(0x027A), AllegianceUpdateDone (0x01C8), AllegianceUpdateAborted (0x0003,
declared but never sent by ACE).

The heavyweight AllegianceUpdate (0x0020) extends
ClientCommandResponses.ParseAllegianceInfoResponse (0x027C) rather than a
second parser, per lane C §7.2's explicit reuse verdict -- both messages
now share ReadAllegianceProfileBody, which the discriminating leading u32
(targetGuid vs rank) is read around. That shared reader implements:

- The ELEVEN AllegianceHierarchy::UnPack version gates (lane C §4.2) --
  officers/spokesperson-skip, officer titles, the four broadcast
  counters, motd/motdSetBy, chatRoomId, bind point, allegianceName,
  isLocked, approvedVassal, each behind its own oldVersion threshold.
  AllegianceProfileVersionGateTests.cs pins all eleven with a
  boundary-crossing pair per gate (N-1 OFF vs N ON), including the
  negative proof that version 5 (BannedCharactersAdded) gates nothing
  in UnPack.
- The §4.4 tree-assembly rules: a record whose treeParent is not already
  in the tree (orphan), equals its own id (self-parent), or duplicates an
  id already seen makes AllegianceHierarchy::Add fail, which the whole
  parse now mirrors by returning null for the ENTIRE message -- not a
  partial tree. Sibling order REVERSES on assembly (each new record is
  prepended to its parent's vassal list), so FindVassals now walks
  records in reverse wire order; both rules have dedicated tests.
- AllegianceMemberRecord gained the panel-needed columns lane C §7.2
  names (rank, level, loyalty, leadership, cpCached, cpTithed, gender,
  heritage, MayPassupExperience) with defaulted trailing parameters so
  existing 4-arg positional construction sites keep compiling. Officers/
  officer titles/bind point are read (so every later field lands at the
  right offset) but deliberately left unsurfaced -- ACE always zeroes/
  empties them anyway (lane C §5.1), and bind point is a 32-byte Position
  the retail chat renderer never uses either; a future panel slice can
  extend the record without re-deriving the parse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-12 00:13:10 +02:00
parent 7be86f47f6
commit 6bedbc4772
6 changed files with 1570 additions and 107 deletions

View file

@ -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.
/// <summary>
/// One retail <c>AllegianceData</c> record. <paramref name="ParentGuid"/>
/// is the wire's "treeParent" tag (0 for the monarch, who has none) —
/// retail's own <c>GetPatron</c>/<c>GetFirstVassal</c> walk the flat
/// record list by this tag rather than storing an explicit tree.
/// One retail <c>AllegianceData</c> record (lane C §4.1). <paramref
/// name="ParentGuid"/> is the wire's "treeParent" tag (0 for the
/// monarch, who has none) — retail's own <c>GetPatron</c>/
/// <c>GetFirstVassal</c> 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.
/// </summary>
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);
/// <summary>
/// <see cref="AllegianceIndex.LoggedIn"/>/<c>HasAllegianceAge</c>/
/// <c>HasPackedLevel</c> bit values — ACE
/// <c>Source/ACE.Server/Network/Enum/AllegianceIndex.cs</c>.
/// <c>HasPackedLevel</c>/<c>MayPassupExperience</c> bit values — ACE
/// <c>Source/ACE.Server/Network/Enum/AllegianceIndex.cs</c>, matching
/// retail's own enum verbatim (<c>acclient.h:7714-7722</c>).
/// </summary>
private const uint LoggedInBit = 0x1u;
private const uint HasAllegianceAgeBit = 0x4u;
private const uint HasPackedLevelBit = 0x8u;
private const uint MayPassupExperienceBit = 0x10u;
/// <summary>
/// The version-gated hierarchy-level fields plus the assembled record
/// list — the parts of <c>AllegianceProfile</c>/<c>AllegianceHierarchy</c>
/// that do NOT differ between <c>0x027C</c> (leading guid) and
/// <c>0x0020</c> (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
/// <c>Position</c> 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).
/// </summary>
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<AllegianceMemberRecord> Records);
/// <summary>
/// Shared lookup logic for both <see cref="AllegianceInfoResponse"/>
/// and <see cref="AllegianceUpdate"/> — the flat record list plus
/// <c>ParentGuid</c> tags IS the tree (lane C §0's DELETE verdict on
/// <c>Core/Allegiance/AllegianceTree.cs</c>); these are ports of
/// retail's own pointer-walk accessors (lane C §1.5).
/// </summary>
private static class AllegianceProfileLookups
{
/// <summary>Port of <c>AllegianceProfile::GetData</c>.</summary>
public static AllegianceMemberRecord? FindData(
AllegianceMemberRecord? monarch,
IReadOnlyList<AllegianceMemberRecord> 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;
}
/// <summary>
/// Port of <c>AllegianceProfile::GetPatron</c>: the monarch has no
/// patron; anyone else's patron is <see cref="FindData"/> applied
/// to their own record's <see cref="AllegianceMemberRecord.ParentGuid"/>.
/// </summary>
public static AllegianceMemberRecord? FindPatron(
AllegianceMemberRecord? monarch,
IReadOnlyList<AllegianceMemberRecord> 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;
}
/// <summary>
/// Port of <c>GetFirstVassal</c>/<c>GetNextVassal</c>. Lane C §4.4
/// point 3: each new record is PREPENDED to its parent's vassal
/// list on assembly (<c>_peer = parent-&gt;_vassal; parent-&gt;_vassal
/// = node;</c>), 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.
/// </summary>
public static IEnumerable<AllegianceMemberRecord> FindVassals(
IReadOnlyList<AllegianceMemberRecord> 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<AllegianceMemberRecord> Records)
IReadOnlyList<AllegianceMemberRecord> Records,
ushort OldVersion = 0,
string Motd = "",
string MotdSetBy = "",
uint ChatRoomId = 0,
uint NameLastSetTime = 0,
bool IsLocked = false,
uint ApprovedVassal = 0)
{
/// <summary>
/// Port of <c>AllegianceProfile::GetData</c>: find the record
/// (monarch or otherwise) whose own <c>characterID</c> matches
/// <paramref name="guid"/>.
/// </summary>
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);
/// <summary>
/// Port of <c>AllegianceProfile::GetPatron</c>: the monarch has no
/// patron; anyone else's patron is <see cref="FindData"/> applied to
/// their own record's <see cref="AllegianceMemberRecord.ParentGuid"/>
/// (which is the monarch's own guid when the patron IS the monarch —
/// ACE never emits a separate patron record in that case, see
/// <c>AllegianceHierarchy.Write</c>'s <c>!node.Patron.IsMonarch</c>
/// guard).
/// </summary>
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);
/// <summary>Port of <c>GetFirstVassal</c>/<c>GetNextVassal</c>: every record whose parent is <paramref name="guid"/>.</summary>
public IEnumerable<AllegianceMemberRecord> FindVassals(uint guid)
{
foreach (AllegianceMemberRecord record in Records)
{
if (record.ParentGuid == guid)
yield return record;
}
}
public IEnumerable<AllegianceMemberRecord> FindVassals(uint guid) =>
AllegianceProfileLookups.FindVassals(Records, guid);
}
public static AllegianceInfoResponse? ParseAllegianceInfoResponse(ReadOnlySpan<byte> 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<ObjectGuid,AllegianceOfficerLevel>.
// 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.
/// <summary>
/// <c>0x0020 AllegianceUpdate</c> — the unsolicited/subscribed profile
/// push (lane C §2 row 5, §4.5): a leading <c>u32 rank</c>, then the
/// SAME <c>AllegianceProfile</c> body <c>0x027C</c> carries. Pushed on
/// every tree change to every online member regardless of whether the
/// panel ever sent <c>0x001F</c> (lane C §5.2) — a client that never
/// subscribes still receives it.
/// </summary>
public readonly record struct AllegianceUpdate(
uint Rank,
uint TotalMembers,
uint TotalVassals,
ushort RecordCount,
string AllegianceName,
AllegianceMemberRecord? Monarch,
IReadOnlyList<AllegianceMemberRecord> 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<AllegianceMemberRecord> FindVassals(uint guid) =>
AllegianceProfileLookups.FindVassals(Records, guid);
}
public static AllegianceUpdate? ParseAllegianceUpdate(ReadOnlySpan<byte> 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; }
}
/// <summary>
/// Reads everything after the profile's leading discriminator u32
/// (targetGuid for <c>0x027C</c>, rank for <c>0x0020</c>) — the eleven
/// version gates (lane C §4.2) followed by the monarch + record list
/// with the §4.4 tree-assembly rules enforced. Returns <see
/// langword="null"/> (never throws) when a record's treeParent is
/// orphaned, self-referential, or a duplicate id — the same "discard
/// the whole message" outcome <c>AllegianceHierarchy::UnPack</c>
/// produces on an <c>Add</c> failure. Truncation still throws
/// <see cref="FormatException"/>, caught by both callers' try/catch.
/// </summary>
private static AllegianceProfileBody? ReadAllegianceProfileBody(ReadOnlySpan<byte> 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<string>.Write — a bare int32 count (NOT the
// PackableHashTable u16/u16 header), then N String16L.
// §4.2 gate 3: officer titles (oldVersion >= 9,
// OfficersTitlesAdded) — PSmartArray<PString>: 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<AllegianceMemberRecord>();
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<AllegianceMemberRecord>();
if (recordCount > 0)
{
AllegianceMemberRecord monarchRecord = ReadAllegianceData(payload, ref pos, parentGuid: 0u);
monarch = monarchRecord;
var knownIds = new HashSet<uint> { 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<byte> 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);
}
/// <summary>

View file

@ -539,6 +539,324 @@ public static class GameEvents
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
}
/// <summary>
/// The <c>ConfirmationType</c> discriminator carried by all three legs
/// of the shared confirmation triple (<c>0x0274</c>/<c>0x0275</c>/
/// <c>0x0276</c>'s leading <c>Type</c>/<c>confirmationType</c> field).
/// Retail <c>Handle_Character__ConfirmationRequest @0x005640A0</c>'s
/// switch and ACE's <c>ConfirmationType.cs:5-12</c> agree byte-for-byte
/// (lane B §3.15, lane C §1.3). Campaign FA needs exactly
/// <see cref="SwearAllegiance"/> (1) and <see cref="Fellowship"/> (4)
/// — D6.
/// </summary>
public enum ConfirmationType : uint
{
SwearAllegiance = 1,
AlterSkill = 2,
AlterAttribute = 3,
Fellowship = 4,
CraftInteraction = 5,
Augmentation = 6,
YesNo = 7,
}
/// <summary>
/// <c>0x0275 ConfirmationResponse</c> — the CLIENT→SERVER leg of the
/// confirmation triple (<c>CM_Character::Event_ConfirmationResponse
/// @0x006A1210</c>, lane B §3.15 / lane C §3.3). Unlike
/// <see cref="CharacterConfirmationRequest"/>/<see cref="CharacterConfirmationDone"/>
/// this direction is never received by a real client — acdream already
/// builds it (<c>ClientCommandRequests.BuildConfirmationResponse</c>).
/// This record + parser exist to give the triple a complete, TYPED
/// representation in Core.Net (the <see cref="ConfirmationType"/>
/// enum, not a bare <c>uint</c>) and a round-trip conformance check —
/// see <c>ConfirmationResponseTests</c> for the golden-vector /
/// round-trip pair against <c>BuildConfirmationResponse</c>.
/// </summary>
public readonly record struct ConfirmationResponse(
ConfirmationType Type,
uint ContextId,
bool Accepted);
public static ConfirmationResponse? ParseConfirmationResponse(ReadOnlySpan<byte> 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.
/// <summary>
/// One retail <c>Fellow</c> record — <c>Fellow::Pack @0x005B9A10</c> /
/// <c>Fellow::UnPack @0x005B9AD0</c> (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 <c>0x02BE</c>, or a separate leading field in
/// <c>0x02C0</c>) — carried here as <see cref="Guid"/> for convenience
/// since both callers already have it in hand when they construct this
/// record. <see cref="ShareLoot"/> is the RAW wire u32, never a bool —
/// ACE encodes it two mutually-inconsistent ways (<c>0x10</c> in full
/// updates, <c>&lt;&lt;1</c> in incremental updates), so the only safe
/// read is <c>ShareLoot != 0</c>, never <c>== 1</c> (lane B §4.1, D5).
/// </summary>
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);
/// <summary>One entry of the <c>_fellows_departed</c> hash table (lane B §2.11/§3.9 field 8).</summary>
public readonly record struct FellowshipDepartedMember(uint Guid, int DepartedTimestamp);
/// <summary>
/// <c>0x02BE FellowshipFullUpdate</c> — <c>Fellowship::Pack</c>/
/// <c>UnPack @0x005B94F0</c> (lane B §3.9). Field 9 (the lock-name
/// table) is intentionally NOT parsed here: retail's own
/// <c>Fellowship::UnPack</c> stops reading after field 8 and never
/// consumes it (lane B §2.7 caution), and lane B's own U3 flags
/// <c>FellowshipLockData</c>'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.
/// </summary>
public readonly record struct FellowshipFullUpdate(
IReadOnlyList<FellowMember> Members,
string Name,
uint LeaderGuid,
bool ShareXp,
bool EvenXpSplit,
bool OpenFellow,
bool Locked,
IReadOnlyList<FellowshipDepartedMember> Departed);
public static FellowshipFullUpdate? ParseFellowshipFullUpdate(ReadOnlySpan<byte> 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<FellowMember>(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<FellowshipDepartedMember>(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; }
}
/// <summary>
/// <c>0x02C0 FellowshipUpdateFellow</c> —
/// <c>DispatchUI_UpdateFellow @0x006A6700</c> (lane B §3.10):
/// <c>[u32 guid][Fellow][u32 updateType]</c>, guid FIRST (Chorizite's
/// generated shape omits the guid — resolved retail-wins per lane B's
/// "Reference disagreement, resolved" note). <c>updateType</c>: 0
/// Undef, 1 Full, 2 UpdateStats (ACE never sends this), 3 UpdateVitals.
/// </summary>
public readonly record struct FellowshipUpdateFellow(
uint MemberGuid,
FellowMember Member,
uint UpdateType);
public static FellowshipUpdateFellow? ParseFellowshipUpdateFellow(ReadOnlySpan<byte> 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; }
}
/// <summary>
/// <c>0x00A3 FellowshipQuit</c>, the S→C direction —
/// <c>DispatchUI_Quit @0x006A5F5F</c> (lane B §3.12):
/// <c>[u32 quitterGuid]</c>. 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
/// <c>SocialActions.BuildFellowshipQuit</c> — a different payload
/// shape entirely, disambiguated here by record name.)
/// </summary>
public readonly record struct FellowshipQuitNotice(uint QuitterGuid);
public static FellowshipQuitNotice? ParseFellowshipQuit(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return new FellowshipQuitNotice(BinaryPrimitives.ReadUInt32LittleEndian(payload));
}
/// <summary>
/// <c>0x00A4 FellowshipDismiss</c>, the S→C direction —
/// <c>DispatchUI_Dismiss @0x006A5EC9</c> (lane B §3.13):
/// <c>[u32 dismissedGuid]</c>. ACE's own comment notes the same numeric
/// opcode value is used in both directions with different envelopes.
/// </summary>
public readonly record struct FellowshipDismissNotice(uint DismissedGuid);
public static FellowshipDismissNotice? ParseFellowshipDismiss(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return new FellowshipDismissNotice(BinaryPrimitives.ReadUInt32LittleEndian(payload));
}
/// <summary>
/// <c>0x02BF FellowshipDisband</c> — empty body
/// (<c>DispatchUI_Disband @0x006A5E80</c> reads only the opcode; ACE
/// writes no body — lane B §3.11).
/// </summary>
public static bool ParseFellowshipDisband(ReadOnlySpan<byte> payload) => payload.Length == 0;
/// <summary>
/// <c>0x01C9 FellowshipFellowUpdateDone</c> — dead in the Sept-2013
/// client: both this and <see cref="FellowshipFellowStatsDone"/>
/// 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). <see cref="RawValue"/>
/// is surfaced only for diagnostics.
/// </summary>
public readonly record struct FellowshipFellowUpdateDone(uint? RawValue);
public static FellowshipFellowUpdateDone ParseFellowshipFellowUpdateDone(ReadOnlySpan<byte> payload)
=> new(payload.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(payload) : null);
/// <summary>
/// <c>0x01CA FellowshipFellowStatsDone</c> — same dead-COMDAT-fold
/// class as <see cref="FellowshipFellowUpdateDone"/> (lane B §2.7);
/// kept as a distinct record type to match the distinct
/// <see cref="GameEventType"/> id even though the shape is identical.
/// </summary>
public readonly record struct FellowshipFellowStatsDone(uint? RawValue);
public static FellowshipFellowStatsDone ParseFellowshipFellowStatsDone(ReadOnlySpan<byte> payload)
=> new(payload.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(payload) : null);
private static FellowMember ReadFellow(ReadOnlySpan<byte> 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<byte> 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<byte> 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.
/// <summary>
/// <c>0x027A AllegianceLoginNotification</c> —
/// <c>DispatchUI_AllegianceLoginNotificationEvent @0x006A6920</c> (lane
/// C §2 row 8, §4.5): <c>[u32 characterGuid][u32 isLoggedIn]</c>. 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.
/// </summary>
public readonly record struct AllegianceLoginNotification(uint CharacterGuid, bool IsLoggedIn);
public static AllegianceLoginNotification? ParseAllegianceLoginNotification(ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
return new AllegianceLoginNotification(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)) != 0u);
}
/// <summary>
/// <c>0x01C8 AllegianceUpdateDone</c> — the panel busy-cursor
/// terminator (lane C §1.2, §2 row 6): a single <c>WeenieError</c>
/// u32 (0 on success).
/// </summary>
public static uint? ParseAllegianceUpdateDone(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>
/// <c>0x0003 AllegianceUpdateAborted</c> — declared by retail
/// (<c>DispatchUI_AllegianceUpdateAborted @0x006A6950</c>) but never
/// actually sent by ACE (lane C §2 row 7, §5.4) — a single
/// <c>WeenieError</c> u32, parsed for completeness/forward-compat.
/// </summary>
public static uint? ParseAllegianceUpdateAborted(ReadOnlySpan<byte> payload)
{
if (payload.Length < 4) return null;
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
// ── House ────────────────────────────────────────────────────────────────
/// <summary>