diff --git a/src/AcDream.Core.Net/Messages/SocialActions.cs b/src/AcDream.Core.Net/Messages/SocialActions.cs
new file mode 100644
index 0000000..86f7524
--- /dev/null
+++ b/src/AcDream.Core.Net/Messages/SocialActions.cs
@@ -0,0 +1,174 @@
+using System;
+using System.Buffers.Binary;
+using System.Text;
+
+namespace AcDream.Core.Net.Messages;
+
+///
+/// Outbound social + query GameActions. These share a common pattern:
+/// single-target / single-parameter GameActions inside the 0xF7B1
+/// envelope.
+///
+///
+/// Wire format:
+///
+/// u32 0xF7B1 envelope
+/// u32 gameActionSequence
+/// u32 subOpcode
+/// <payload> per-action
+///
+///
+///
+///
+/// References: r08 §3 rows for each opcode.
+///
+///
+public static class SocialActions
+{
+ public const uint GameActionEnvelope = 0xF7B1u;
+
+ // Queries
+ public const uint QueryHealthOpcode = 0x01BFu; // u32 targetGuid
+ public const uint PingRequestOpcode = 0x01E9u; // u32 clientId
+
+ // Fellowship
+ public const uint FellowshipCreateOpcode = 0x00A2u; // string16L name, bool openness, bool shareXP
+ public const uint FellowshipQuitOpcode = 0x00A3u; // bool disband
+ public const uint FellowshipDismissOpcode = 0x00A4u; // u32 guid
+ public const uint FellowshipRecruitOpcode = 0x00A5u; // u32 guid
+ public const uint FellowshipUpdateOpcode = 0x00A6u; // bool open
+
+ // Character options
+ public const uint SetCharacterOptionsOpcode = 0x01A1u; // u32 options bitmap
+
+ // Chat channels
+ public const uint AddChannelOpcode = 0x0145u; // string16L channelName
+ public const uint RemoveChannelOpcode = 0x0146u; // string16L channelName
+
+ /// Query a target's health — server replies with UpdateHealth (0x01C0).
+ public static byte[] BuildQueryHealth(uint seq, uint targetGuid)
+ {
+ byte[] body = new byte[16];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), QueryHealthOpcode);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), targetGuid);
+ return body;
+ }
+
+ /// Keepalive ping — server echoes with PingResponse (0x01EA).
+ public static byte[] BuildPingRequest(uint seq, uint clientId)
+ {
+ byte[] body = new byte[16];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), PingRequestOpcode);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), clientId);
+ return body;
+ }
+
+ /// Create a fellowship with a chosen name + options.
+ public static byte[] BuildFellowshipCreate(
+ uint seq, string fellowshipName, bool openness, 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];
+ 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;
+ return body;
+ }
+
+ /// Quit your current fellowship (optionally disband if leader).
+ public static byte[] BuildFellowshipQuit(uint seq, bool disband)
+ {
+ byte[] body = new byte[16]; // envelope + 1 byte bool aligned to 4
+ BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), FellowshipQuitOpcode);
+ body[12] = disband ? (byte)1 : (byte)0;
+ return body;
+ }
+
+ /// Dismiss a specific vassal from your fellowship.
+ public static byte[] BuildFellowshipDismiss(uint seq, uint targetGuid)
+ => SingleGuid(seq, FellowshipDismissOpcode, targetGuid);
+
+ /// Recruit a target into your fellowship.
+ 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)
+ {
+ 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;
+ return body;
+ }
+
+ /// Push the client's character-options bitmap to the server.
+ public static byte[] BuildSetCharacterOptions(uint seq, uint optionsBitmap)
+ {
+ byte[] body = new byte[16];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), SetCharacterOptionsOpcode);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), optionsBitmap);
+ return body;
+ }
+
+ /// Subscribe to a named chat channel.
+ public static byte[] BuildAddChannel(uint seq, string channelName)
+ => SingleString(seq, AddChannelOpcode, channelName);
+
+ /// Unsubscribe from a named chat channel.
+ public static byte[] BuildRemoveChannel(uint seq, string channelName)
+ => SingleString(seq, RemoveChannelOpcode, channelName);
+
+ // ── Helpers ──────────────────────────────────────────────────────────────
+
+ private static byte[] SingleGuid(uint seq, uint sub, uint guid)
+ {
+ byte[] body = new byte[16];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), sub);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), guid);
+ return body;
+ }
+
+ private static byte[] SingleString(uint seq, uint sub, string s)
+ {
+ byte[] str = PackString16L(s);
+ byte[] body = new byte[12 + str.Length];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), sub);
+ Array.Copy(str, 0, body, 12, str.Length);
+ return body;
+ }
+
+ private static byte[] PackString16L(string s)
+ {
+ ArgumentNullException.ThrowIfNull(s);
+ byte[] data = Encoding.ASCII.GetBytes(s);
+ if (data.Length > ushort.MaxValue)
+ throw new ArgumentException("String too long for 16-bit length prefix.", nameof(s));
+
+ int recordSize = 2 + data.Length;
+ int padding = (4 - (recordSize & 3)) & 3;
+ byte[] result = new byte[recordSize + padding];
+ BinaryPrimitives.WriteUInt16LittleEndian(result, (ushort)data.Length);
+ Array.Copy(data, 0, result, 2, data.Length);
+ return result;
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs
new file mode 100644
index 0000000..3018ae1
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs
@@ -0,0 +1,111 @@
+using System;
+using System.Buffers.Binary;
+using System.Text;
+using AcDream.Core.Net.Messages;
+using Xunit;
+
+namespace AcDream.Core.Net.Tests.Messages;
+
+public sealed class SocialActionsTests
+{
+ [Fact]
+ public void BuildQueryHealth_HasOpcode0x01BFAndGuid()
+ {
+ byte[] body = SocialActions.BuildQueryHealth(seq: 3, targetGuid: 0xCAFE);
+ Assert.Equal(SocialActions.QueryHealthOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
+ Assert.Equal(0xCAFEu,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
+ }
+
+ [Fact]
+ public void BuildPingRequest_HasOpcode0x01E9()
+ {
+ byte[] body = SocialActions.BuildPingRequest(seq: 1, clientId: 42);
+ Assert.Equal(SocialActions.PingRequestOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
+ Assert.Equal(42u,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
+ }
+
+ [Fact]
+ public void BuildFellowshipCreate_StringThenBools()
+ {
+ byte[] body = SocialActions.BuildFellowshipCreate(
+ seq: 1, fellowshipName: "Team", openness: true, shareXp: false);
+
+ 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 BuildFellowshipQuit_CarriesDisbandFlag()
+ {
+ byte[] body = SocialActions.BuildFellowshipQuit(seq: 1, disband: true);
+ Assert.Equal(SocialActions.FellowshipQuitOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
+ Assert.Equal(1, body[12]);
+ }
+
+ [Fact]
+ public void BuildFellowshipDismiss_HasGuid()
+ {
+ byte[] body = SocialActions.BuildFellowshipDismiss(seq: 1, targetGuid: 0xDEAD);
+ Assert.Equal(SocialActions.FellowshipDismissOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
+ Assert.Equal(0xDEADu,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
+ }
+
+ [Fact]
+ public void BuildFellowshipRecruit_HasGuid()
+ {
+ byte[] body = SocialActions.BuildFellowshipRecruit(seq: 1, targetGuid: 0xBEEF);
+ Assert.Equal(SocialActions.FellowshipRecruitOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
+ }
+
+ [Fact]
+ public void BuildFellowshipUpdate_HasOpenBool()
+ {
+ byte[] body = SocialActions.BuildFellowshipUpdate(seq: 1, open: true);
+ Assert.Equal(SocialActions.FellowshipUpdateOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
+ Assert.Equal(1, body[12]);
+ }
+
+ [Fact]
+ public void BuildSetCharacterOptions_HasBitmap()
+ {
+ byte[] body = SocialActions.BuildSetCharacterOptions(seq: 1, optionsBitmap: 0xDEADBEEFu);
+ Assert.Equal(0xDEADBEEFu,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
+ }
+
+ [Fact]
+ public void BuildAddChannel_ContainsName()
+ {
+ byte[] body = SocialActions.BuildAddChannel(seq: 1, channelName: "Trade");
+ Assert.Equal(SocialActions.AddChannelOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
+ ushort len = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(12));
+ Assert.Equal(5, len);
+ Assert.Equal("Trade", Encoding.ASCII.GetString(body.AsSpan(14, 5)));
+ }
+
+ [Fact]
+ public void BuildRemoveChannel_HasOpcode0x0146()
+ {
+ byte[] body = SocialActions.BuildRemoveChannel(seq: 1, channelName: "General");
+ Assert.Equal(SocialActions.RemoveChannelOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
+ }
+}