feat(net) Campaign CA CA2 #431: parse the inbound attribute/skill update family
Some checks failed
CI / linux-portable (push) Successful in 3m32s
CI / windows-gate (push) Successful in 6m22s
CI / release (push) Has been cancelled

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:
Erik 2026-08-24 13:38:50 +02:00
parent 1fc64984c9
commit 65430d4c7c
9 changed files with 474 additions and 4 deletions

View file

@ -0,0 +1,69 @@
using System;
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound primary-attribute update <c>GameMessage</c> for the local player
/// (<c>0x02E3</c>) — the server's authoritative answer to a RaiseAttribute
/// action (and any other server-side attribute change). A standalone
/// GameMessage like <see cref="PrivateUpdateVital"/>, NOT a <c>0xF7B0</c>
/// GameEvent.
///
/// <para>
/// Campaign CA slice CA2 (#431): until this parser existed the client's
/// attribute model was stale from login's PlayerDescription until the next
/// login — every post-raise derived value (skills, vitals maxima, run
/// rate) computed from old attributes.
/// </para>
///
/// <para>
/// Wire layout — three-source agreement (ACE
/// <c>GameMessagePrivateUpdateAttribute.cs:8-16</c>; Chorizite
/// <c>AttributeInfo.generated.cs:26-54</c>; holtburger
/// <c>player/types.rs:22-53</c> <c>UpdateAttribute&lt;false&gt;</c>), full
/// citations in
/// <c>docs/research/2026-08-24-advancement-wire-and-recompute.md</c> §2.5:
/// </para>
/// <code>
/// PrivateUpdateAttribute (0x02E3):
/// u32 opcode = 0x02E3
/// u8 sequence // ByteSequence, per-attribute counter
/// u32 attribute // PropertyAttribute (1=Strength..6=Self)
/// u32 ranks
/// u32 start // StartingValue / InitLevel
/// u32 xp // ExperienceSpent / CPSpent
/// </code>
/// </summary>
public static class PrivateUpdateAttribute
{
public const uint Opcode = 0x02E3u;
/// <summary>Parsed attribute update.</summary>
public readonly record struct Parsed(
byte Sequence,
uint AttributeId,
uint Ranks,
uint Start,
uint Xp);
/// <summary>
/// Parse a raw <c>PrivateUpdateAttribute (0x02E3)</c> body. Returns
/// <c>null</c> on opcode mismatch or truncation.
/// </summary>
public static Parsed? TryParse(ReadOnlySpan<byte> body)
{
// 4 (opcode) + 1 (seq) + 4 * 4 (uints) = 21 bytes minimum.
if (body.Length < 21) return null;
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body);
if (opcode != Opcode) return null;
int pos = 4;
byte seq = body[pos]; pos += 1;
uint attr = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
uint ranks = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
uint start = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
uint xp = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]);
return new Parsed(seq, attr, ranks, start, xp);
}
}

View file

@ -0,0 +1,85 @@
using System;
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound skill update <c>GameMessage</c> for the local player
/// (<c>0x02DD</c>) — the server's authoritative answer to RaiseSkill /
/// TrainSkill / specialize / untrain / reset. A standalone GameMessage
/// like <see cref="PrivateUpdateVital"/>, NOT a <c>0xF7B0</c> GameEvent.
///
/// <para>
/// Campaign CA slice CA2 (#431). NOTE the opcode-neighborhood trap this
/// slice also corrected: 0x02DD is <b>PrivateUpdateSkill</b>, not
/// PrivateUpdatePropertyString (which is 0x02D5) — ACE
/// <c>GameMessageOpcode.cs:21,29</c>. The related ranks-only
/// <c>PrivateUpdateSkillLevel (0x02DF)</c> has NO producer anywhere in
/// ACE (verified 2026-08-24) and is deliberately not parsed.
/// </para>
///
/// <para>
/// Wire layout — three-source agreement (ACE
/// <c>GameMessagePrivateUpdateSkill.cs:8-24</c>; Chorizite
/// <c>Skill.generated.cs:22-86</c>; holtburger
/// <c>player/types.rs:71-131</c> with the golden fixture at
/// <c>:279-293</c> confirming <c>adjustPP=1</c> on real captures), full
/// citations in
/// <c>docs/research/2026-08-24-advancement-wire-and-recompute.md</c> §2.8:
/// </para>
/// <code>
/// PrivateUpdateSkill (0x02DD):
/// u32 opcode = 0x02DD
/// u8 sequence // ByteSequence, per-skill counter
/// u32 skillId // Skill enum ordinal
/// u16 ranks // LevelFromPP — ushort on the wire!
/// u16 adjustPP // hardcoded 1 by ACE on every send
/// u32 advancementClass // SkillAdvancementClass (1=Untrained/2=Trained/3=Specialized)
/// u32 xp // ExperienceSpent / PP
/// u32 init // InitLevel
/// u32 resistance // ResistanceAtLastCheck
/// f64 lastUsedTime
/// </code>
/// </summary>
public static class PrivateUpdateSkill
{
public const uint Opcode = 0x02DDu;
/// <summary>Parsed skill update. Ranks widened from the wire's u16.</summary>
public readonly record struct Parsed(
byte Sequence,
uint SkillId,
uint Ranks,
ushort AdjustPP,
uint AdvancementClass,
uint Xp,
uint Init,
uint Resistance,
double LastUsed);
/// <summary>
/// Parse a raw <c>PrivateUpdateSkill (0x02DD)</c> body. Returns
/// <c>null</c> on opcode mismatch or truncation.
/// </summary>
public static Parsed? TryParse(ReadOnlySpan<byte> body)
{
// 4 (opcode) + 1 (seq) + 4 + 2 + 2 + 4*4 + 8 = 37 bytes minimum.
if (body.Length < 37) return null;
uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body);
if (opcode != Opcode) return null;
int pos = 4;
byte seq = body[pos]; pos += 1;
uint skillId = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
ushort ranks = BinaryPrimitives.ReadUInt16LittleEndian(body[pos..]); pos += 2;
ushort adjustPP = BinaryPrimitives.ReadUInt16LittleEndian(body[pos..]); pos += 2;
uint sac = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
uint xp = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
uint init = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
uint resistance = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4;
double lastUsed = BitConverter.Int64BitsToDouble(
BinaryPrimitives.ReadInt64LittleEndian(body[pos..]));
return new Parsed(
seq, skillId, ranks, adjustPP, sac, xp, init, resistance, lastUsed);
}
}

View file

@ -500,6 +500,21 @@ public sealed class WorldSession : IDisposable
/// </summary>
public event Action<PrivateUpdateVital.ParsedCurrent>? VitalCurrentUpdated;
/// <summary>
/// Campaign CA CA2 (#431): fires when a
/// <c>PrivateUpdateAttribute (0x02E3)</c> arrives — the authoritative
/// primary-attribute record after a raise. Subscribers typically feed
/// <see cref="AcDream.Core.Player.LocalPlayerState.OnAttributeUpdate"/>.
/// </summary>
public event Action<PrivateUpdateAttribute.Parsed>? AttributeUpdated;
/// <summary>
/// Campaign CA CA2 (#431): fires when a
/// <c>PrivateUpdateSkill (0x02DD)</c> arrives — the authoritative skill
/// record after raise / train / specialize / untrain / reset.
/// </summary>
public event Action<PrivateUpdateSkill.Parsed>? SkillUpdated;
/// <summary>
/// Phase 6 — server-broadcast PhysicsScript trigger. Fires when the
/// server sends a <c>PlayScriptId</c> (opcode 0xF754) packet —
@ -2257,6 +2272,24 @@ public sealed class WorldSession : IDisposable
if (parsed is not null)
VitalCurrentUpdated?.Invoke(parsed.Value);
}
else if (op == PrivateUpdateAttribute.Opcode)
{
// Campaign CA CA2 (#431): authoritative attribute record
// after RaiseAttribute. Wire per ACE
// GameMessagePrivateUpdateAttribute (3-source agreement).
var parsed = PrivateUpdateAttribute.TryParse(body);
if (parsed is not null)
AttributeUpdated?.Invoke(parsed.Value);
}
else if (op == PrivateUpdateSkill.Opcode)
{
// Campaign CA CA2 (#431): authoritative skill record after
// raise/train/specialize/untrain/reset. Wire per ACE
// GameMessagePrivateUpdateSkill (3-source agreement).
var parsed = PrivateUpdateSkill.TryParse(body);
if (parsed is not null)
SkillUpdated?.Invoke(parsed.Value);
}
else if (op == PublicUpdatePropertyInt.Opcode)
{
var p = PublicUpdatePropertyInt.TryParse(body);