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>
This commit is contained in:
parent
1fc64984c9
commit
65430d4c7c
9 changed files with 474 additions and 4 deletions
131
tests/AcDream.Core.Net.Tests/PrivateUpdateAttributeSkillTests.cs
Normal file
131
tests/AcDream.Core.Net.Tests/PrivateUpdateAttributeSkillTests.cs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
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)));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue