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
69
src/AcDream.Core.Net/Messages/PrivateUpdateAttribute.cs
Normal file
69
src/AcDream.Core.Net/Messages/PrivateUpdateAttribute.cs
Normal 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<false></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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue