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>
104 lines
4.6 KiB
C#
104 lines
4.6 KiB
C#
using System.Buffers.Binary;
|
|
|
|
namespace AcDream.Core.Net.Messages;
|
|
|
|
/// <summary>
|
|
/// Outbound character-progression + mode-change GameActions. Grouped
|
|
/// together because they share the same <c>0xF7B1</c> envelope +
|
|
/// single-opcode dispatch pattern and all target the session's player
|
|
/// (no target guid field).
|
|
///
|
|
/// <para>
|
|
/// References: r08 §3 rows 0x0044 / 0x0045 / 0x0046 / 0x0047 / 0x0053.
|
|
/// </para>
|
|
/// </summary>
|
|
public static class CharacterActions
|
|
{
|
|
public const uint GameActionEnvelope = 0xF7B1u;
|
|
|
|
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
|
|
|
|
[Flags]
|
|
public enum CombatMode : uint
|
|
{
|
|
Undef = 0,
|
|
NonCombat = 0x01,
|
|
Melee = 0x02,
|
|
Missile = 0x04,
|
|
Magic = 0x08,
|
|
|
|
ValidCombat = NonCombat | Melee | Missile | Magic,
|
|
CombatCombat = Melee | Missile | Magic,
|
|
}
|
|
|
|
/// <summary>Spend XP to raise an attribute (Strength, Endurance, etc).</summary>
|
|
public static byte[] BuildRaiseAttribute(uint seq, uint attrId, ulong xpSpent)
|
|
=> BuildAttrOrVital(seq, RaiseAttributeOpcode, attrId, xpSpent);
|
|
|
|
/// <summary>Spend XP to raise a vital (Health, Stamina, Mana).</summary>
|
|
public static byte[] BuildRaiseVital(uint seq, uint vitalId, ulong xpSpent)
|
|
=> BuildAttrOrVital(seq, RaiseVitalOpcode, vitalId, xpSpent);
|
|
|
|
/// <summary>Spend XP to raise a skill.</summary>
|
|
public static byte[] BuildRaiseSkill(uint seq, uint skillId, ulong xpSpent)
|
|
=> BuildAttrOrVital(seq, RaiseSkillOpcode, skillId, xpSpent);
|
|
|
|
/// <summary>Spend skill credits to train (unlock) a skill.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Envelope + sequence + sub-opcode + id + <b>32-bit</b> xpSpent.
|
|
///
|
|
/// <para>The xpSpent field is a dword on the wire, not a qword. ACE's
|
|
/// <c>GameAction/Actions/GameActionRaiseAttribute.cs</c> (and its Vital and
|
|
/// Skill siblings) read <c>message.Payload.ReadUInt32()</c>, and
|
|
/// holtburger's <c>RaiseAttributeData</c> declares <c>xp_spent: u32</c>
|
|
/// 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.</para>
|
|
///
|
|
/// <para>The parameter stays <c>ulong</c> 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.</para>
|
|
/// </summary>
|
|
private static byte[] BuildAttrOrVital(uint seq, uint sub, uint id, ulong xp)
|
|
{
|
|
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.WriteUInt32LittleEndian(body.AsSpan(16), (uint)xp);
|
|
return body;
|
|
}
|
|
}
|