acdream/tests/AcDream.Core.Net.Tests/PrivateUpdateAttributeSkillTests.cs
Erik 65430d4c7c
Some checks failed
CI / linux-portable (push) Successful in 3m32s
CI / windows-gate (push) Successful in 6m22s
CI / release (push) Has been cancelled
feat(net) Campaign CA CA2 #431: parse the inbound attribute/skill update family
The server's authoritative answers to a raise were dropped on the floor:
only the vitals pair (0x02E7/0x02E9) had parsers, so after any
RaiseAttribute/RaiseSkill/TrainSkill the client's stat model stayed
frozen at login's PlayerDescription — the root cause of #431's stale
derived skills and run speed. The GUI looked alive only because the
panel applies optimistic local raises.

New parsers with three-source-verified layouts (CA1 research doc §2.5/
§2.8): PrivateUpdateAttribute (0x02E3) and PrivateUpdateSkill (0x02DD —
the wire's ushort ranks + hardcoded adjustPP=1 pair and f64
lastUsedTime preserved exactly). WorldSession dispatches both as typed
events; LiveSessionEventRouter routes them into the J4 character owner's
LocalPlayerState like every other private update. The vestigial
PrivateUpdateSkillLevel (0x02DF) is deliberately unparsed — ACE has no
producer (verified).

OnAttributeUpdate now fans out to the derived-value observers, mirroring
retail's live-at-inquiry model (CACQualities::InqSkill 0x00592660 —
Set* writes raw, Inq* recomputes, notification carries no value): an
Endurance write notifies the Health AND Stamina vital observers (ACE
pushes only a Health record and its own comment says the client must
refresh both), Self notifies Mana, and every attribute write notifies
character-sheet consumers whose formula contributions just changed.
OnSkillWireUpdate preserves the login FormulaBonus — the wire record
carries no attribute contribution; CA3 replaces the cached field with
the live computation.

Also corrected while in the neighborhood: PropertyString.cs's comment
claimed opcode 0x02DD for PrivateUpdatePropertyString; ACE's enum says
0x02D5/0x02D6 (doc-only — nothing dispatched on either).

Conformance tests cover both layouts (including holtburger's golden
skill fixture with adjustPP=1), truncation/wrong-opcode rejection, the
Endurance/Self/Quickness fan-out contract, and FormulaBonus
preservation. Full hermetic suite 15,333 passed / 0 failed (one
load-sensitive transport flake observed on the first run, passed alone
and on the clean re-run — filed as #439 rather than chased).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:38:50 +02:00

131 lines
4.9 KiB
C#

using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
namespace AcDream.Core.Net.Tests;
/// <summary>
/// Campaign CA CA2 (#431) wire-format tests for
/// <see cref="PrivateUpdateAttribute.TryParse"/> and
/// <see cref="PrivateUpdateSkill.TryParse"/>. Layouts carry three-source
/// agreement (ACE producer + Chorizite generated type + holtburger
/// implementation); the skill round-trip mirrors holtburger's golden
/// fixture (<c>types.rs:279-293</c> — ranks 50, adjustPP 1, status 3,
/// xp 1000, init 10, resistance 0, lastUsed 0.0). Full citations:
/// <c>docs/research/2026-08-24-advancement-wire-and-recompute.md</c>
/// §2.5 / §2.8.
/// </summary>
public sealed class PrivateUpdateAttributeSkillTests
{
private static byte[] BuildAttribute(
byte seq, uint attr, uint ranks, uint start, uint xp)
{
// u32 opcode (0x02E3) + u8 seq + 4 * u32 = 21 bytes
byte[] body = new byte[21];
BinaryPrimitives.WriteUInt32LittleEndian(body, PrivateUpdateAttribute.Opcode);
body[4] = seq;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(5), attr);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(9), ranks);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(13), start);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(17), xp);
return body;
}
private static byte[] BuildSkill(
byte seq,
uint skillId,
ushort ranks,
ushort adjustPP,
uint sac,
uint xp,
uint init,
uint resistance,
double lastUsed)
{
// u32 opcode (0x02DD) + u8 seq + u32 + 2*u16 + 4*u32 + f64 = 37 bytes
byte[] body = new byte[37];
BinaryPrimitives.WriteUInt32LittleEndian(body, PrivateUpdateSkill.Opcode);
body[4] = seq;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(5), skillId);
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(9), ranks);
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(11), adjustPP);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(13), sac);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(17), xp);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(21), init);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(25), resistance);
BinaryPrimitives.WriteInt64LittleEndian(
body.AsSpan(29), BitConverter.DoubleToInt64Bits(lastUsed));
return body;
}
[Fact]
public void Attribute_RoundTrip()
{
// A Quickness (3) raise: 41 ranks over a 100 start, 1,010,895 xp.
var bytes = BuildAttribute(seq: 7, attr: 3, ranks: 41, start: 100, xp: 1_010_895);
var p = PrivateUpdateAttribute.TryParse(bytes);
Assert.NotNull(p);
Assert.Equal((byte)7, p!.Value.Sequence);
Assert.Equal(3u, p.Value.AttributeId);
Assert.Equal(41u, p.Value.Ranks);
Assert.Equal(100u, p.Value.Start);
Assert.Equal(1_010_895u, p.Value.Xp);
}
[Fact]
public void Attribute_RejectsWrongOpcodeAndTruncation()
{
var bytes = BuildAttribute(1, 1, 1, 10, 100);
BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0x02E7u);
Assert.Null(PrivateUpdateAttribute.TryParse(bytes));
var good = BuildAttribute(1, 1, 1, 10, 100);
Assert.Null(PrivateUpdateAttribute.TryParse(good.AsSpan(0, 20)));
}
[Fact]
public void Skill_RoundTrip_HoltburgerGoldenFixture()
{
// holtburger types.rs:279-293 — adjustPP=1 confirmed on a real
// capture, matching ACE's hardcoded constant.
var bytes = BuildSkill(
seq: 12, skillId: 6, ranks: 50, adjustPP: 1, sac: 3,
xp: 1000, init: 10, resistance: 0, lastUsed: 0.0);
var p = PrivateUpdateSkill.TryParse(bytes);
Assert.NotNull(p);
Assert.Equal((byte)12, p!.Value.Sequence);
Assert.Equal(6u, p.Value.SkillId);
Assert.Equal(50u, p.Value.Ranks);
Assert.Equal((ushort)1, p.Value.AdjustPP);
Assert.Equal(3u, p.Value.AdvancementClass);
Assert.Equal(1000u, p.Value.Xp);
Assert.Equal(10u, p.Value.Init);
Assert.Equal(0u, p.Value.Resistance);
Assert.Equal(0.0, p.Value.LastUsed);
}
[Fact]
public void Skill_LastUsedSurvivesAsDoubleBits()
{
var bytes = BuildSkill(1, 14, 3, 1, 2, 42, 0, 5, 12345.678);
var p = PrivateUpdateSkill.TryParse(bytes);
Assert.NotNull(p);
Assert.Equal(12345.678, p!.Value.LastUsed);
}
[Fact]
public void Skill_RejectsWrongOpcodeAndTruncation()
{
var bytes = BuildSkill(1, 6, 1, 1, 2, 0, 0, 0, 0.0);
BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0x02DFu);
Assert.Null(PrivateUpdateSkill.TryParse(bytes));
var good = BuildSkill(1, 6, 1, 1, 2, 0, 0, 0, 0.0);
Assert.Null(PrivateUpdateSkill.TryParse(good.AsSpan(0, 36)));
}
}