diff --git a/src/AcDream.Core.Net/Messages/AllegianceRequests.cs b/src/AcDream.Core.Net/Messages/AllegianceRequests.cs index a7bac791..ebb2b5d4 100644 --- a/src/AcDream.Core.Net/Messages/AllegianceRequests.cs +++ b/src/AcDream.Core.Net/Messages/AllegianceRequests.cs @@ -3,25 +3,32 @@ using System.Buffers.Binary; namespace AcDream.Core.Net.Messages; /// -/// Outbound allegiance GameActions. Both Swear and Break carry a +/// Outbound allegiance GameActions. Swear, Break, and Kick all carry a /// single uint32 target-guid payload inside the standard /// 0xF7B1 GameAction envelope. /// /// -/// Wire layout (r11 §2.1 / §2.3): +/// Wire layout — byte-verified against +/// docs/research/2026-08-11-fa-allegiance-wire.md §3.2 (lane C), +/// three-way agreed with ACE + Chorizite there (the retail anchor is the +/// opcode literal store inside each CM_Allegiance::Event_* sender, +/// the strongest possible provenance): /// /// u32 0xF7B1 /// u32 gameActionSequence -/// u32 subOpcode (0x001D or 0x001E) +/// u32 subOpcode (0x001D, 0x001E) /// u32 targetGuid /// +/// total 0x10 bytes for both. No change was needed here in Campaign FA +/// slice FA1 (2026-08-11) — the pre-existing shape already matched. /// /// /// /// Server replies with GameEventAllegianceUpdate (0x0020) and /// GameEventAllegianceAllegianceUpdateDone (0x01C8) on success, /// or a WeenieError on failure (already sworn, already maxed -/// vassals, target not online, etc — see r11 §2.1 for the full list). +/// vassals, target not online, etc — see lane C §2 master table for the +/// full list). /// /// public static class AllegianceRequests @@ -39,12 +46,32 @@ public static class AllegianceRequests /// /// Break your pledge to . Target can be /// your patron (breaking from) OR your vassal (breaking them away). + /// Retail's Break button (element 0x10000264) always targets + /// your own patron — see for the vassal-facing + /// sibling button. /// public static byte[] BuildBreak(uint gameActionSequence, uint targetGuid) { return Build(gameActionSequence, BreakOpcode, targetGuid); } + /// + /// Kick a vassal out of your allegiance. Wire-identical to + /// — both are Event_BreakAllegiance(u32) + /// @0x006A69DA (opcode 0x001E); retail's Kick button + /// (gmAllegianceUI::CloseKickConfirmationDialog @0x00490B00) + /// targets the selected vassal (m_iidPossibleKickedVassal) + /// where Break targets AllegianceProfile::GetPatron(self) (lane + /// C §1.4). Named separately purely so FA2's panel command surface can + /// distinguish "break from patron" from "kick a vassal" without both + /// call sites reading like the same action; the golden vector is + /// identical to 's. + /// + public static byte[] BuildKick(uint gameActionSequence, uint vassalGuid) + { + return Build(gameActionSequence, BreakOpcode, vassalGuid); + } + private static byte[] Build(uint seq, uint sub, uint targetGuid) { byte[] body = new byte[16]; diff --git a/src/AcDream.Core.Net/Messages/SocialActions.cs b/src/AcDream.Core.Net/Messages/SocialActions.cs index 9d52e30f..f7977705 100644 --- a/src/AcDream.Core.Net/Messages/SocialActions.cs +++ b/src/AcDream.Core.Net/Messages/SocialActions.cs @@ -33,12 +33,24 @@ public static class SocialActions public const uint QueryItemManaOpcode = 0x0263u; // u32 itemGuid; zero cancels public const uint PingRequestOpcode = 0x01E9u; // no payload - // Fellowship - public const uint FellowshipCreateOpcode = 0x00A2u; // string16L name, bool openness, bool shareXP - public const uint FellowshipQuitOpcode = 0x00A3u; // bool disband + // Fellowship — field orders byte-verified against + // docs/research/2026-08-11-fa-fellowship-wire.md §3.1-§3.7 (lane B), + // three-way agreed with ACE + holtburger there. Campaign FA slice FA1 + // (2026-08-11) repaired two latent defects here (lane B §5.2): (a) + // FellowshipCreateOpcode's body never had an "openness" field — the + // prior builder invented one and silently sent it as the low byte of + // shareXP, which ACE would read as an INVERTED shareXP value; (b) + // FellowshipUpdateOpcode (0x00A6) is FellowshipUpdateRequest — panel + // VISIBILITY, not fellowship openness (that's the new 0x0291 below) — + // the prior "open" parameter name/doc was wrong even though its single + // bool byte happened to be in the right wire position. + public const uint FellowshipCreateOpcode = 0x00A2u; // string16L name, u32 shareXP + public const uint FellowshipQuitOpcode = 0x00A3u; // u32 disband (0/1) public const uint FellowshipDismissOpcode = 0x00A4u; // u32 guid public const uint FellowshipRecruitOpcode = 0x00A5u; // u32 guid - public const uint FellowshipUpdateOpcode = 0x00A6u; // bool open + public const uint FellowshipUpdateRequestOpcode = 0x00A6u; // u32 panelOpen (0/1) — panel visibility, NOT openness + public const uint FellowshipAssignNewLeaderOpcode = 0x0290u; // u32 newLeaderGuid + public const uint FellowshipChangeOpennessOpcode = 0x0291u; // u32 isOpen (0/1) — the REAL openness toggle // Character options // CH3 (2026-08-09): the full-blob SetCharacterOptions (0x01A1) builder @@ -119,21 +131,26 @@ public static class SocialActions return body; } - /// Create a fellowship with a chosen name + options. - public static byte[] BuildFellowshipCreate( - uint seq, string fellowshipName, bool openness, bool shareXp) + /// + /// Create a fellowship with a chosen name. There is no "openness" + /// field on this message — retail's gmFellowshipUI::CreateFellowship + /// @0x0048F730 writes exactly [str16L name][u32 shareXP] + /// (lane B §2.2/§3.1). is the + /// FellowshipShareXP character option (0x0F), not a + /// dialog checkbox — retail reads + /// PlayerModule::FellowshipShareXP at create time and sends its + /// current value. + /// + public static byte[] BuildFellowshipCreate(uint seq, string fellowshipName, bool shareXp) { byte[] name = PackString16L(fellowshipName); - // 2 bools consume 2 bytes + alignment pad to 4. - int boolBlock = 2; - int pad = (4 - ((name.Length + boolBlock) & 3)) & 3; - byte[] body = new byte[12 + name.Length + boolBlock + pad]; + byte[] body = new byte[12 + name.Length + 4]; BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), FellowshipCreateOpcode); Array.Copy(name, 0, body, 12, name.Length); - body[12 + name.Length] = openness ? (byte)1 : (byte)0; - body[12 + name.Length + 1] = shareXp ? (byte)1 : (byte)0; + BinaryPrimitives.WriteUInt32LittleEndian( + body.AsSpan(12 + name.Length), shareXp ? 1u : 0u); return body; } @@ -156,14 +173,59 @@ public static class SocialActions public static byte[] BuildFellowshipRecruit(uint seq, uint targetGuid) => SingleGuid(seq, FellowshipRecruitOpcode, targetGuid); - /// Toggle fellowship open / closed recruiting. - public static byte[] BuildFellowshipUpdate(uint seq, bool open) + /// + /// Declare the fellowship panel's visibility to the server + /// (0x00A6 FellowshipUpdateRequest). This is NOT fellowship + /// openness — retail sends it from + /// gmFellowshipUI::OnVisibilityChanged @0x0048E460 on panel + /// show/hide, and ACE gates the whole 0x02C0 member-vitals + /// stream on it (Fellowship.cs:723): a client that never sends + /// this sees names/levels/max-vitals from 0x02BE but a roster + /// frozen at join time (lane B §4.5, §12). See + /// for the real openness + /// toggle, which is a separate opcode this builder used to be + /// (incorrectly) named for. + /// + public static byte[] BuildFellowshipUpdateRequest(uint seq, bool panelOpen) { byte[] body = new byte[16]; BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), FellowshipUpdateOpcode); - body[12] = open ? (byte)1 : (byte)0; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), FellowshipUpdateRequestOpcode); + body[12] = panelOpen ? (byte)1 : (byte)0; + return body; + } + + /// + /// Transfer fellowship leadership to another member + /// (0x0290 FellowshipAssignNewLeader). Retail's Quit button, when + /// pressed by the current leader, sends this FIRST (targeting + /// Fellowship::GetNonLeaderFellowID) and only then sends + /// 0x00A3 disband=0 — + /// gmFellowshipUI Quit button case 8 @0x0049034B (lane B + /// §2.5/§3.6). AssignLeadershipToFellow @0x0048F1E0 is the + /// direct (non-quit) caller. + /// + public static byte[] BuildFellowshipAssignNewLeader(uint seq, uint newLeaderGuid) + => SingleGuid(seq, FellowshipAssignNewLeaderOpcode, newLeaderGuid); + + /// + /// Toggle fellowship open / closed recruiting — the REAL openness + /// action (0x0291 FellowshipChangeOpenness). Retail's Open + /// button (case 9) calls + /// Event_ChangeFellowOpeness @0x006A6040 after pre-toggling its + /// own _open_fellow optimistically (lane B §2.5/§3.7) — the + /// bytes are identical in shape to the old (misnamed) + /// , but this is a distinct + /// opcode acdream did not previously send at all. + /// + public static byte[] BuildFellowshipChangeOpenness(uint seq, bool isOpen) + { + byte[] body = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), FellowshipChangeOpennessOpcode); + body[12] = isOpen ? (byte)1 : (byte)0; return body; } diff --git a/tests/AcDream.Core.Net.Tests/Messages/AllegianceRequestsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/AllegianceRequestsTests.cs index 848d79c4..eddc60c4 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/AllegianceRequestsTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/AllegianceRequestsTests.cs @@ -32,4 +32,60 @@ public sealed class AllegianceRequestsTests Assert.Equal(0xBBBBu, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); } + + // FA1: golden byte vectors, hand-computed from lane C §3.2 + // (`CM_Allegiance::Event_SwearAllegiance`/`Event_BreakAllegiance`), + // not generated by calling the builder under test. + [Fact] + public void BuildSwear_GoldenByteVector() + { + byte[] body = AllegianceRequests.BuildSwear(gameActionSequence: 3, patronGuid: 0xAAAAu); + + byte[] expected = + [ + 0xB1, 0xF7, 0x00, 0x00, // envelope 0xF7B1 + 0x03, 0x00, 0x00, 0x00, // seq 3 + 0x1D, 0x00, 0x00, 0x00, // opcode 0x001D + 0xAA, 0xAA, 0x00, 0x00, // targetGuid 0xAAAA + ]; + + Assert.Equal(expected, body); + } + + [Fact] + public void BuildBreak_GoldenByteVector() + { + byte[] body = AllegianceRequests.BuildBreak(gameActionSequence: 5, targetGuid: 0xBBBBu); + + byte[] expected = + [ + 0xB1, 0xF7, 0x00, 0x00, // envelope 0xF7B1 + 0x05, 0x00, 0x00, 0x00, // seq 5 + 0x1E, 0x00, 0x00, 0x00, // opcode 0x001E + 0xBB, 0xBB, 0x00, 0x00, // targetGuid 0xBBBB + ]; + + Assert.Equal(expected, body); + } + + // New builder — BuildKick is BuildBreak's opcode with a vassal target + // (lane C §1.4). Same golden vector shape as BuildBreak, proving the + // wire is identical while the call site reads distinctly for FA2. + [Fact] + public void BuildKick_GoldenByteVector_SameShapeAsBreak() + { + byte[] body = AllegianceRequests.BuildKick(gameActionSequence: 6, vassalGuid: 0x50000042u); + + byte[] expected = + [ + 0xB1, 0xF7, 0x00, 0x00, // envelope 0xF7B1 + 0x06, 0x00, 0x00, 0x00, // seq 6 + 0x1E, 0x00, 0x00, 0x00, // opcode 0x001E — SAME as Break + 0x42, 0x00, 0x00, 0x50, // vassalGuid 0x50000042 + ]; + + Assert.Equal(expected, body); + Assert.Equal(AllegianceRequests.BreakOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + } } diff --git a/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs index ce1ab493..a02d1928 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs @@ -49,22 +49,48 @@ public sealed class SocialActionsTests BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); } + // FA1 (Campaign FA slice FA1, 2026-08-11): re-pinned per + // docs/research/2026-08-11-fa-fellowship-wire.md §3.1/§5.2(a). The + // PRIOR version of this test asserted a wrong shape — a fabricated + // "openness" byte at offset 20 that does not exist on the wire at all + // — pinning the SAME bug lane B found: ACE reads that byte as the low + // byte of a u32 shareXP, silently inverting it. The golden vector + // below is HAND-COMPUTED field-by-field from + // `CM_Fellowship::Event_Create @0x006A67A0` (`[u32 0xA2][str16L + // name][u32 shareXP]`, buffer size strPackSize+0x10), not generated by + // calling the builder under test — the OP1 convention + // (SocialActionsTests.cs:136-142 states the rule this file already + // follows for BuildSetCharacterOptions). [Fact] - public void BuildFellowshipCreate_StringThenBools() + public void BuildFellowshipCreate_GoldenByteVector_StringThenShareXpU32() { byte[] body = SocialActions.BuildFellowshipCreate( - seq: 1, fellowshipName: "Team", openness: true, shareXp: false); + seq: 7, fellowshipName: "Team", shareXp: true); + byte[] expected = + [ + 0xB1, 0xF7, 0x00, 0x00, // envelope 0xF7B1 + 0x07, 0x00, 0x00, 0x00, // seq 7 + 0xA2, 0x00, 0x00, 0x00, // opcode 0x00A2 + 0x04, 0x00, 0x54, 0x65, 0x61, 0x6D, 0x00, 0x00, // str16L "Team": u16 len=4, "Team", pad 2 + 0x01, 0x00, 0x00, 0x00, // shareXP = 1 (true) — a full u32, NOT a byte + ]; + + Assert.Equal(expected, body); Assert.Equal(SocialActions.FellowshipCreateOpcode, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); - // String at offset 12: u16 length = 4 - ushort len = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(12)); - Assert.Equal(4, len); - Assert.Equal("Team", Encoding.ASCII.GetString(body.AsSpan(14, 4))); - // string16L record = 2+4=6, pad 2 → advance by 8. - // Then 2 bools at offset 20,21. - Assert.Equal(1, body[20]); // openness true - Assert.Equal(0, body[21]); // shareXp false + } + + [Fact] + public void BuildFellowshipCreate_ShareXpFalse_EncodesZeroU32() + { + byte[] body = SocialActions.BuildFellowshipCreate( + seq: 1, fellowshipName: "X", shareXp: false); + + // str16L "X": len=1, "X", pad to 4 => 4 bytes total (2+1+1 pad). + Assert.Equal(12 + 4 + 4, body.Length); + Assert.Equal(0u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(body.Length - 4))); } [Fact] @@ -94,13 +120,71 @@ public sealed class SocialActionsTests BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); } + // FA1: renamed from BuildFellowshipUpdate/"open" — this opcode (0x00A6 + // FellowshipUpdateRequest) is the panel-VISIBILITY declaration, not + // fellowship openness (lane B §3.5). The pinned byte shape is + // unchanged (SocialActionsTests.cs:98-105 pre-FA1 pinned the byte, not + // the meaning, so it survived the rename per lane B §5.2(b)) — this + // test just carries the corrected name. [Fact] - public void BuildFellowshipUpdate_HasOpenBool() + public void BuildFellowshipUpdateRequest_GoldenByteVector_PanelOpenBool() { - byte[] body = SocialActions.BuildFellowshipUpdate(seq: 1, open: true); - Assert.Equal(SocialActions.FellowshipUpdateOpcode, + byte[] body = SocialActions.BuildFellowshipUpdateRequest(seq: 9, panelOpen: true); + + byte[] expected = + [ + 0xB1, 0xF7, 0x00, 0x00, // envelope + 0x09, 0x00, 0x00, 0x00, // seq 9 + 0xA6, 0x00, 0x00, 0x00, // opcode 0x00A6 + 0x01, 0x00, 0x00, 0x00, // panelOpen = 1 + ]; + + Assert.Equal(expected, body); + Assert.Equal(SocialActions.FellowshipUpdateRequestOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + } + + // New builder — 0x0290 FellowshipAssignNewLeader (lane B §2.5/§3.6). + // Golden vector hand-computed from `Event_AssignNewLeader + // @0x006A5F70`: `[u32 0x290][u32 newLeaderGuid]`, total 0x10. + [Fact] + public void BuildFellowshipAssignNewLeader_GoldenByteVector() + { + byte[] body = SocialActions.BuildFellowshipAssignNewLeader(seq: 2, newLeaderGuid: 0x50001234u); + + byte[] expected = + [ + 0xB1, 0xF7, 0x00, 0x00, // envelope + 0x02, 0x00, 0x00, 0x00, // seq 2 + 0x90, 0x02, 0x00, 0x00, // opcode 0x0290 + 0x34, 0x12, 0x00, 0x50, // newLeaderGuid 0x50001234 + ]; + + Assert.Equal(expected, body); + Assert.Equal(SocialActions.FellowshipAssignNewLeaderOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + } + + // New builder — 0x0291 FellowshipChangeOpenness, the REAL openness + // toggle (lane B §2.5/§3.7). Golden vector hand-computed from + // `Event_ChangeFellowOpeness @0x006A6040`: `[u32 0x291][u32 isOpen]`, + // total 0x10. + [Fact] + public void BuildFellowshipChangeOpenness_GoldenByteVector() + { + byte[] body = SocialActions.BuildFellowshipChangeOpenness(seq: 4, isOpen: false); + + byte[] expected = + [ + 0xB1, 0xF7, 0x00, 0x00, // envelope + 0x04, 0x00, 0x00, 0x00, // seq 4 + 0x91, 0x02, 0x00, 0x00, // opcode 0x0291 + 0x00, 0x00, 0x00, 0x00, // isOpen = 0 (false) + ]; + + Assert.Equal(expected, body); + Assert.Equal(SocialActions.FellowshipChangeOpennessOpcode, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); - Assert.Equal(1, body[12]); } [Fact]