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.
|
// ACE: GameEventAllegianceInfoResponse.cs -> AllegianceProfileExtensions.
|
||||||
// Write / AllegianceHierarchyExtensions.Write / AllegianceDataExtensions.
|
// Write / AllegianceHierarchyExtensions.Write / AllegianceDataExtensions.
|
||||||
// Write. Retail: ClientAllegianceSystem::
|
// Write. Retail: ClientAllegianceSystem::
|
||||||
// Handle_Allegiance__AllegianceInfoResponseEvent @0x0056a1d0, walking
|
// Handle_Allegiance__AllegianceInfoResponseEvent @0x0056a1d0, walking
|
||||||
// AllegianceProfile::GetData/GetPatron/GetFirstVassal/GetNextVassal.
|
// 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>
|
/// <summary>
|
||||||
/// One retail <c>AllegianceData</c> record. <paramref name="ParentGuid"/>
|
/// One retail <c>AllegianceData</c> record (lane C §4.1). <paramref
|
||||||
/// is the wire's "treeParent" tag (0 for the monarch, who has none) —
|
/// name="ParentGuid"/> is the wire's "treeParent" tag (0 for the
|
||||||
/// retail's own <c>GetPatron</c>/<c>GetFirstVassal</c> walk the flat
|
/// monarch, who has none) — retail's own <c>GetPatron</c>/
|
||||||
/// record list by this tag rather than storing an explicit tree.
|
/// <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>
|
/// </summary>
|
||||||
public readonly record struct AllegianceMemberRecord(
|
public readonly record struct AllegianceMemberRecord(
|
||||||
uint CharacterId,
|
uint CharacterId,
|
||||||
uint ParentGuid,
|
uint ParentGuid,
|
||||||
bool IsLoggedIn,
|
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>
|
/// <summary>
|
||||||
/// <see cref="AllegianceIndex.LoggedIn"/>/<c>HasAllegianceAge</c>/
|
/// <see cref="AllegianceIndex.LoggedIn"/>/<c>HasAllegianceAge</c>/
|
||||||
/// <c>HasPackedLevel</c> bit values — ACE
|
/// <c>HasPackedLevel</c>/<c>MayPassupExperience</c> bit values — ACE
|
||||||
/// <c>Source/ACE.Server/Network/Enum/AllegianceIndex.cs</c>.
|
/// <c>Source/ACE.Server/Network/Enum/AllegianceIndex.cs</c>, matching
|
||||||
|
/// retail's own enum verbatim (<c>acclient.h:7714-7722</c>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private const uint LoggedInBit = 0x1u;
|
private const uint LoggedInBit = 0x1u;
|
||||||
private const uint HasAllegianceAgeBit = 0x4u;
|
private const uint HasAllegianceAgeBit = 0x4u;
|
||||||
private const uint HasPackedLevelBit = 0x8u;
|
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(
|
public readonly record struct AllegianceInfoResponse(
|
||||||
uint TargetGuid,
|
uint TargetGuid,
|
||||||
|
|
@ -187,55 +301,23 @@ public static class ClientCommandResponses
|
||||||
ushort RecordCount,
|
ushort RecordCount,
|
||||||
string AllegianceName,
|
string AllegianceName,
|
||||||
AllegianceMemberRecord? Monarch,
|
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>
|
public AllegianceMemberRecord? FindData(uint guid) =>
|
||||||
/// Port of <c>AllegianceProfile::GetData</c>: find the record
|
AllegianceProfileLookups.FindData(Monarch, Records, guid);
|
||||||
/// (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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public AllegianceMemberRecord? FindPatron(uint guid) =>
|
||||||
/// Port of <c>AllegianceProfile::GetPatron</c>: the monarch has no
|
AllegianceProfileLookups.FindPatron(Monarch, Records, guid);
|
||||||
/// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Port of <c>GetFirstVassal</c>/<c>GetNextVassal</c>: every record whose parent is <paramref name="guid"/>.</summary>
|
public IEnumerable<AllegianceMemberRecord> FindVassals(uint guid) =>
|
||||||
public IEnumerable<AllegianceMemberRecord> FindVassals(uint guid)
|
AllegianceProfileLookups.FindVassals(Records, guid);
|
||||||
{
|
|
||||||
foreach (AllegianceMemberRecord record in Records)
|
|
||||||
{
|
|
||||||
if (record.ParentGuid == guid)
|
|
||||||
yield return record;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static AllegianceInfoResponse? ParseAllegianceInfoResponse(ReadOnlySpan<byte> payload)
|
public static AllegianceInfoResponse? ParseAllegianceInfoResponse(ReadOnlySpan<byte> payload)
|
||||||
|
|
@ -244,95 +326,248 @@ public static class ClientCommandResponses
|
||||||
{
|
{
|
||||||
int pos = 0;
|
int pos = 0;
|
||||||
uint targetGuid = ReadU32(payload, ref pos);
|
uint targetGuid = ReadU32(payload, ref pos);
|
||||||
uint totalMembers = ReadU32(payload, ref pos);
|
AllegianceProfileBody? body = ReadAllegianceProfileBody(payload, ref pos);
|
||||||
uint totalVassals = ReadU32(payload, ref pos);
|
if (body is null) return null; // §4.4 orphan/self-parent/duplicate — retail discards the WHOLE message
|
||||||
ushort recordCount = ReadU16(payload, ref pos);
|
AllegianceProfileBody b = body.Value;
|
||||||
_ = ReadU16(payload, ref pos); // oldVersion — not consulted by the renderer
|
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>.
|
/// <summary>
|
||||||
// ACE always sends this empty ("always sent as empty in retail?"
|
/// <c>0x0020 AllegianceUpdate</c> — the unsolicited/subscribed profile
|
||||||
// per AllegianceHierarchy.cs) and retail's own chat renderer never
|
/// push (lane C §2 row 5, §4.5): a leading <c>u32 rank</c>, then the
|
||||||
// reads it — skip the entries, keep the cursor faithful.
|
/// 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);
|
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++)
|
for (int i = 0; i < officerCount; i++)
|
||||||
{
|
{
|
||||||
_ = ReadU32(payload, ref pos); // guid
|
_ = ReadU32(payload, ref pos); // guid
|
||||||
_ = ReadU32(payload, ref pos); // officer level
|
_ = 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
|
// §4.2 gate 3: officer titles (oldVersion >= 9,
|
||||||
// PackableHashTable u16/u16 header), then N String16L.
|
// OfficersTitlesAdded) — PSmartArray<PString>: a bare i32 count,
|
||||||
|
// NOT the PackableHashTable u16/u16 header.
|
||||||
|
if (oldVersion >= 9)
|
||||||
|
{
|
||||||
int titleCount = unchecked((int)ReadU32(payload, ref pos));
|
int titleCount = unchecked((int)ReadU32(payload, ref pos));
|
||||||
for (int i = 0; i < titleCount; i++)
|
for (int i = 0; i < titleCount; i++)
|
||||||
_ = StringReader.ReadString16L(payload, ref pos);
|
_ = 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); // monarchBroadcastTime
|
||||||
_ = ReadU32(payload, ref pos); // monarchBroadcastsToday
|
_ = ReadU32(payload, ref pos); // monarchBroadcastsToday
|
||||||
_ = ReadU32(payload, ref pos); // spokesBroadcastTime
|
_ = ReadU32(payload, ref pos); // spokesBroadcastTime
|
||||||
_ = ReadU32(payload, ref pos); // spokesBroadcastsToday
|
_ = 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,
|
// §4.2 gate 5 (MotdAdded, oldVersion >= 3).
|
||||||
// W/X/Y/Z order) = 32 bytes. Not surfaced by the retail chat
|
string motd = "";
|
||||||
// renderer (only the Allegiance UI panel's bind-point display
|
string motdSetBy = "";
|
||||||
// would use it) — skip with bounds checking via ReadU32.
|
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++)
|
for (int i = 0; i < 8; i++)
|
||||||
_ = ReadU32(payload, ref pos);
|
_ = 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(
|
private static AllegianceMemberRecord ReadAllegianceData(
|
||||||
ReadOnlySpan<byte> payload, ref int pos, uint parentGuid)
|
ReadOnlySpan<byte> payload, ref int pos, uint parentGuid)
|
||||||
{
|
{
|
||||||
uint characterId = ReadU32(payload, ref pos);
|
uint characterId = ReadU32(payload, ref pos);
|
||||||
_ = ReadU32(payload, ref pos); // cpCached
|
uint cpCached = ReadU32(payload, ref pos);
|
||||||
_ = ReadU32(payload, ref pos); // cpTithed
|
uint cpTithed = ReadU32(payload, ref pos);
|
||||||
uint bitfield = ReadU32(payload, ref pos);
|
uint bitfield = ReadU32(payload, ref pos);
|
||||||
_ = ReadByte(payload, ref pos); // gender
|
byte gender = ReadByte(payload, ref pos);
|
||||||
_ = ReadByte(payload, ref pos); // heritage group
|
byte heritageGroup = ReadByte(payload, ref pos);
|
||||||
_ = ReadU16(payload, ref pos); // rank
|
ushort rank = ReadU16(payload, ref pos);
|
||||||
|
uint level = 0;
|
||||||
if ((bitfield & HasPackedLevelBit) != 0u)
|
if ((bitfield & HasPackedLevelBit) != 0u)
|
||||||
_ = ReadU32(payload, ref pos); // level
|
level = ReadU32(payload, ref pos);
|
||||||
_ = ReadU16(payload, ref pos); // loyalty
|
ushort loyalty = ReadU16(payload, ref pos);
|
||||||
_ = ReadU16(payload, ref pos); // leadership
|
ushort leadership = ReadU16(payload, ref pos);
|
||||||
if ((bitfield & HasAllegianceAgeBit) != 0u)
|
if ((bitfield & HasAllegianceAgeBit) != 0u)
|
||||||
{
|
{
|
||||||
_ = ReadU32(payload, ref pos); // timeOnline
|
_ = ReadU32(payload, ref pos); // timeOnline — ACE hard-codes 0 forever (lane C §5.1)
|
||||||
_ = ReadU32(payload, ref pos); // allegianceAge
|
_ = ReadU32(payload, ref pos); // allegianceAge — same
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_ = ReadU32(payload, ref pos); // uTimeOnline low
|
_ = ReadU32(payload, ref pos); // legacy uTimeOnline low (double, pre-HasAllegianceAge)
|
||||||
_ = ReadU32(payload, ref pos); // uTimeOnline high
|
_ = ReadU32(payload, ref pos); // legacy uTimeOnline high
|
||||||
}
|
}
|
||||||
string name = StringReader.ReadString16L(payload, ref pos);
|
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(
|
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>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -539,6 +539,324 @@ public static class GameEvents
|
||||||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
|
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><<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 ────────────────────────────────────────────────────────────────
|
// ── House ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,522 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Net.Tests.Messages;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FA slice FA1 (2026-08-11): conformance tests for the ELEVEN
|
||||||
|
/// <c>AllegianceHierarchy::UnPack</c> 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
|
||||||
|
/// <c>ParseAllegianceUpdate</c> (0x0020) sharing the extended
|
||||||
|
/// <c>ParseAllegianceInfoResponse</c> (0x027C) profile reader.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Every version-gate test builds TWO fixtures — <c>oldVersion = N-1</c>
|
||||||
|
/// (field OFF) and <c>oldVersion = N</c> (field ON) — for each of the
|
||||||
|
/// eleven <c>AllegianceVersion</c> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
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") };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds one <c>AllegianceProfile</c> wire body (0x027C/0x0020 shape,
|
||||||
|
/// leading discriminator u32 supplied by the caller) at a given
|
||||||
|
/// <c>oldVersion</c>, writing ONLY the fields that version's gates
|
||||||
|
/// admit — mirrors <c>ACE/Source/.../AllegianceHierarchy.cs</c>'s own
|
||||||
|
/// writer field order, cross-checked against lane C §4.1-§4.2.
|
||||||
|
/// </summary>
|
||||||
|
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<string>();
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Net.Tests.Messages;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FA slice FA1 (2026-08-11): the small fixed-shape allegiance
|
||||||
|
/// S→C events (everything except <c>0x0020 AllegianceUpdate</c> and
|
||||||
|
/// <c>0x027C AllegianceInfoResponse</c>, which reuse the extended
|
||||||
|
/// <c>ClientCommandResponses</c> profile reader — see
|
||||||
|
/// <c>AllegianceProfileVersionGateTests</c>). Field orders per
|
||||||
|
/// docs/research/2026-08-11-fa-allegiance-wire.md (lane C) §4.5. Unwired —
|
||||||
|
/// FA2 registers these against RuntimeAllegianceState.
|
||||||
|
/// </summary>
|
||||||
|
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<byte>.Empty));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
using System.Buffers.Binary;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Net.Tests.Messages;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FA slice FA1 (2026-08-11), D6: completes Core.Net's typed
|
||||||
|
/// representation of the shared confirmation triple
|
||||||
|
/// (<c>0x0274</c>/<c>0x0275</c>/<c>0x0276</c>). Grepping the tree before
|
||||||
|
/// this slice showed <c>0x0274</c> (<see cref="GameEvents.ParseCharacterConfirmationRequest"/>)
|
||||||
|
/// and <c>0x0276</c> (<see cref="GameEvents.ParseCharacterConfirmationDone"/>)
|
||||||
|
/// already had typed record + parser pairs; <c>0x0275</c> — the CLIENT→
|
||||||
|
/// SERVER leg — already had a byte-correct builder
|
||||||
|
/// (<c>ClientCommandRequests.BuildConfirmationResponse</c>, lane C §3.3)
|
||||||
|
/// but no typed record, no <see cref="GameEvents.ConfirmationType"/>
|
||||||
|
/// enum, and no parser. This file pins the completed triple: the new
|
||||||
|
/// <see cref="GameEvents.ConfirmationType"/> enum (1 = SwearAllegiance,
|
||||||
|
/// 4 = Fellowship — D6), and <see cref="GameEvents.ParseConfirmationResponse"/>
|
||||||
|
/// round-tripping against the EXISTING builder byte-for-byte.
|
||||||
|
/// </summary>
|
||||||
|
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]));
|
||||||
|
}
|
||||||
|
}
|
||||||
234
tests/AcDream.Core.Net.Tests/Messages/FellowshipEventsTests.cs
Normal file
234
tests/AcDream.Core.Net.Tests/Messages/FellowshipEventsTests.cs
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
using System;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Net.Tests.Messages;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FA slice FA1 (2026-08-11): golden-vector round-trip tests for
|
||||||
|
/// the fellowship S→C parsers added to <see cref="GameEvents"/>. Fixtures
|
||||||
|
/// are built with <see cref="AceWireWriter"/> (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.
|
||||||
|
/// </summary>
|
||||||
|
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<byte>.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<byte>.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<byte>.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue