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:
parent
7be86f47f6
commit
6bedbc4772
6 changed files with 1570 additions and 107 deletions
|
|
@ -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->_vassal; parent->_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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue