diff --git a/src/AcDream.Core.Net/Messages/CharacterActions.cs b/src/AcDream.Core.Net/Messages/CharacterActions.cs index 4abbbc39..96a86670 100644 --- a/src/AcDream.Core.Net/Messages/CharacterActions.cs +++ b/src/AcDream.Core.Net/Messages/CharacterActions.cs @@ -16,9 +16,9 @@ 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 RaiseAttributeOpcode = 0x0045u; // u32 attr, u32 xpSpent + public const uint RaiseVitalOpcode = 0x0044u; // u32 vital, u32 xpSpent + public const uint RaiseSkillOpcode = 0x0046u; // u32 skillId, u32 xpSpent public const uint TrainSkillOpcode = 0x0047u; // u32 skillId, u32 credits public const uint ChangeCombatModeOpcode = 0x0053u; // u32 combatMode @@ -74,14 +74,31 @@ public static class CharacterActions return body; } + /// + /// Envelope + sequence + sub-opcode + id + 32-bit xpSpent. + /// + /// The xpSpent field is a dword on the wire, not a qword. ACE's + /// GameAction/Actions/GameActionRaiseAttribute.cs (and its Vital and + /// Skill siblings) read message.Payload.ReadUInt32(), and + /// holtburger's RaiseAttributeData declares xp_spent: u32 + /// and advances the offset by four. We were writing eight, making the + /// message 24 bytes where the server expects 20 and leaving four bytes of + /// tail the server never reads. + /// + /// The parameter stays ulong because the cost originates from + /// 64-bit server XP tables several layers up; narrowing that chain end to + /// end is a separate change. No value is lost here: a cost that does not + /// fit in a dword was never expressible on this wire in the first + /// place. + /// private static byte[] BuildAttrOrVital(uint seq, uint sub, uint id, ulong xp) { - byte[] body = new byte[24]; + byte[] body = new byte[20]; 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); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), (uint)xp); return body; } } diff --git a/tests/AcDream.Core.Net.Tests/Messages/CharacterActionsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CharacterActionsTests.cs index 94194619..065f411f 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/CharacterActionsTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/CharacterActionsTests.cs @@ -7,17 +7,40 @@ namespace AcDream.Core.Net.Tests.Messages; public sealed class CharacterActionsTests { + /// + /// xpSpent is a dword, not a qword: ACE's GameActionRaiseAttribute reads + /// ReadUInt32() and holtburger's RaiseAttributeData declares + /// xp_spent: u32. This test previously asserted the 24-byte, + /// 64-bit shape — note that its sibling BuildTrainSkill_U32CreditsNotU64 + /// had already been corrected to 20 bytes for the same class of mistake. + /// [Fact] - public void BuildRaiseAttribute_HasOpcode0x0045AndXp64() + public void BuildRaiseAttribute_HasOpcode0x0045AndU32Xp() { byte[] body = CharacterActions.BuildRaiseAttribute(seq: 1, attrId: 5, xpSpent: 12345678); - Assert.Equal(24, body.Length); + Assert.Equal(20, 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))); + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); + } + + [Theory] + [InlineData(0u)] + [InlineData(1u)] + [InlineData(12345678u)] + [InlineData(uint.MaxValue)] + public void RaiseVitalAndSkill_AlsoWriteU32Xp(uint xp) + { + byte[] vital = CharacterActions.BuildRaiseVital(seq: 1, vitalId: 1, xpSpent: xp); + byte[] skill = CharacterActions.BuildRaiseSkill(seq: 1, skillId: 8, xpSpent: xp); + + Assert.Equal(20, vital.Length); + Assert.Equal(20, skill.Length); + Assert.Equal(xp, BinaryPrimitives.ReadUInt32LittleEndian(vital.AsSpan(16))); + Assert.Equal(xp, BinaryPrimitives.ReadUInt32LittleEndian(skill.AsSpan(16))); } [Fact]