diff --git a/src/AcDream.Core.Net/Messages/CharacterActions.cs b/src/AcDream.Core.Net/Messages/CharacterActions.cs
new file mode 100644
index 0000000..0da9505
--- /dev/null
+++ b/src/AcDream.Core.Net/Messages/CharacterActions.cs
@@ -0,0 +1,79 @@
+using System.Buffers.Binary;
+
+namespace AcDream.Core.Net.Messages;
+
+///
+/// Outbound character-progression + mode-change GameActions. Grouped
+/// together because they share the same 0xF7B1 envelope +
+/// single-opcode dispatch pattern and all target the session's player
+/// (no target guid field).
+///
+///
+/// References: r08 §3 rows 0x0044 / 0x0045 / 0x0046 / 0x0047 / 0x0053.
+///
+///
+public static class CharacterActions
+{
+ public const uint GameActionEnvelope = 0xF7B1u;
+
+ public const uint RaiseAttributeOpcode = 0x0045u; // u32 attr, u64 xpSpent
+ public const uint RaiseVitalOpcode = 0x0044u; // u32 vital, u64 xpSpent
+ public const uint RaiseSkillOpcode = 0x0046u; // u32 skillId, u64 xpSpent
+ public const uint TrainSkillOpcode = 0x0047u; // u32 skillId, u32 credits
+ public const uint ChangeCombatModeOpcode = 0x0053u; // u32 combatMode
+
+ public enum CombatMode : uint
+ {
+ Undef = 0, NonCombat = 1, Melee = 2, Missile = 3, Magic = 4, Peaceful = 5,
+ }
+
+ /// Spend XP to raise an attribute (Strength, Endurance, etc).
+ public static byte[] BuildRaiseAttribute(uint seq, uint attrId, ulong xpSpent)
+ => BuildAttrOrVital(seq, RaiseAttributeOpcode, attrId, xpSpent);
+
+ /// Spend XP to raise a vital (Health, Stamina, Mana).
+ public static byte[] BuildRaiseVital(uint seq, uint vitalId, ulong xpSpent)
+ => BuildAttrOrVital(seq, RaiseVitalOpcode, vitalId, xpSpent);
+
+ /// Spend XP to raise a skill.
+ public static byte[] BuildRaiseSkill(uint seq, uint skillId, ulong xpSpent)
+ => BuildAttrOrVital(seq, RaiseSkillOpcode, skillId, xpSpent);
+
+ /// Spend skill credits to train (unlock) a skill.
+ public static byte[] BuildTrainSkill(uint seq, uint skillId, uint credits)
+ {
+ byte[] body = new byte[20];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), TrainSkillOpcode);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), skillId);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), credits);
+ return body;
+ }
+
+ ///
+ /// Change combat mode. Peace ↔ Melee / Missile / Magic toggles that
+ /// switch stance + weapon ready-state. The animation + weapon
+ /// wield broadcasts are server-driven from this message.
+ ///
+ public static byte[] BuildChangeCombatMode(uint seq, CombatMode mode)
+ {
+ byte[] body = new byte[16];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), ChangeCombatModeOpcode);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), (uint)mode);
+ return body;
+ }
+
+ private static byte[] BuildAttrOrVital(uint seq, uint sub, uint id, ulong xp)
+ {
+ byte[] body = new byte[24];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), sub);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), id);
+ BinaryPrimitives.WriteUInt64LittleEndian(body.AsSpan(16), xp);
+ return body;
+ }
+}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/CharacterActionsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CharacterActionsTests.cs
new file mode 100644
index 0000000..979aeaa
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Messages/CharacterActionsTests.cs
@@ -0,0 +1,60 @@
+using System;
+using System.Buffers.Binary;
+using AcDream.Core.Net.Messages;
+using Xunit;
+
+namespace AcDream.Core.Net.Tests.Messages;
+
+public sealed class CharacterActionsTests
+{
+ [Fact]
+ public void BuildRaiseAttribute_HasOpcode0x0045AndXp64()
+ {
+ byte[] body = CharacterActions.BuildRaiseAttribute(seq: 1, attrId: 5, xpSpent: 12345678);
+ Assert.Equal(24, body.Length);
+ Assert.Equal(CharacterActions.RaiseAttributeOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
+ Assert.Equal(5u,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
+ Assert.Equal(12345678u,
+ BinaryPrimitives.ReadUInt64LittleEndian(body.AsSpan(16)));
+ }
+
+ [Fact]
+ public void BuildRaiseVital_HasOpcode0x0044()
+ {
+ byte[] body = CharacterActions.BuildRaiseVital(seq: 1, vitalId: 1, xpSpent: 100);
+ Assert.Equal(CharacterActions.RaiseVitalOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
+ }
+
+ [Fact]
+ public void BuildRaiseSkill_HasOpcode0x0046()
+ {
+ byte[] body = CharacterActions.BuildRaiseSkill(seq: 1, skillId: 8, xpSpent: 500);
+ Assert.Equal(CharacterActions.RaiseSkillOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
+ }
+
+ [Fact]
+ public void BuildTrainSkill_U32CreditsNotU64()
+ {
+ byte[] body = CharacterActions.BuildTrainSkill(seq: 1, skillId: 8, credits: 4);
+ Assert.Equal(20, body.Length); // 12 + 4 + 4 vs 16 for 4+8
+ Assert.Equal(CharacterActions.TrainSkillOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
+ Assert.Equal(4u,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)));
+ }
+
+ [Fact]
+ public void BuildChangeCombatMode_EnumSerialises()
+ {
+ byte[] body = CharacterActions.BuildChangeCombatMode(
+ seq: 1, CharacterActions.CombatMode.Melee);
+ Assert.Equal(CharacterActions.ChangeCombatModeOpcode,
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)));
+ Assert.Equal(2u, // Melee = 2
+ BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)));
+ }
+}