acdream/tests/AcDream.Core.Net.Tests/Messages/CharacterActionsTests.cs
Erik f57db35cec fix(net): xpSpent is a dword on the wire, and we were sending eight bytes
RaiseAttribute, RaiseVital, and RaiseSkill each wrote a 64-bit xpSpent,
producing a 24-byte action where the server expects 20. ACE's
GameActionRaiseAttribute and its Vital and Skill siblings read
message.Payload.ReadUInt32(); holtburger's RaiseAttributeData declares
xp_spent: u32 and advances the offset by four. Both oracles agree, and the
four extra bytes were tail the server never reads.

These three are live-wired, from the character sheet through the command
router to SendRaiseAttribute, so this was shipping on every attribute, vital,
and skill raise. It has not caused a visible failure because ACE reads the low
dword and stops, and a single raise cost has never approached the dword
ceiling. That is luck about value ranges, not correctness about layout.

Worth noting the shape of the miss: the sibling builder BuildTrainSkill had
already been corrected to a 20-byte, 32-bit credits field, and its test is even
named U32CreditsNotU64. The same class of bug was found and fixed once in this
file and the other three cases were left behind.

The parameter stays ulong because the cost comes from 64-bit server XP tables
several layers up in the App and Runtime command chain; narrowing that end to
end is a separate change and is filed in the audit's open questions. Nothing is
lost at the wire: a cost that does not fit in a dword was never expressible
here.

The existing test asserted the 24-byte shape and is corrected, joined by a
theory that sweeps zero, one, a realistic cost, and uint.MaxValue across both
remaining builders.

Core.Net tests go 655 to 659.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 01:59:47 +02:00

92 lines
3.5 KiB
C#

using System;
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
using Xunit;
namespace AcDream.Core.Net.Tests.Messages;
public sealed class CharacterActionsTests
{
/// <summary>
/// xpSpent is a dword, not a qword: ACE's GameActionRaiseAttribute reads
/// <c>ReadUInt32()</c> and holtburger's RaiseAttributeData declares
/// <c>xp_spent: u32</c>. 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.
/// </summary>
[Fact]
public void BuildRaiseAttribute_HasOpcode0x0045AndU32Xp()
{
byte[] body = CharacterActions.BuildRaiseAttribute(seq: 1, attrId: 5, xpSpent: 12345678);
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.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]
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)));
}
[Fact]
public void CombatMode_UsesRetailAceBitValues()
{
Assert.Equal(1u, (uint)CharacterActions.CombatMode.NonCombat);
Assert.Equal(2u, (uint)CharacterActions.CombatMode.Melee);
Assert.Equal(4u, (uint)CharacterActions.CombatMode.Missile);
Assert.Equal(8u, (uint)CharacterActions.CombatMode.Magic);
}
}