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