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>
85 lines
3.6 KiB
C#
85 lines
3.6 KiB
C#
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);
|
|
}
|
|
}
|