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]