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

@ -24,6 +24,26 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
## #439 — Flake candidate: LossySession_FivePercentSeeded_ZeroMessageLoss_Headroom256 fails under full parallel suite load
**Status:** OPEN (observation filed; do NOT chase individually per docs/release-gate.md)
**Severity:** LOW (test-infra)
**Filed:** 2026-08-24 (one occurrence during Campaign CA CA2's full-suite run)
**Component:** Core.Net.Tests / transport lossy decorator
**Symptom:** `LossyTransportDecoratorTests.LossySession_FivePercentSeeded_ZeroMessageLoss_Headroom256`
failed once under the full parallel hermetic suite with `Headroom expected 256, actual 0`
— i.e. the seeded loss-recovery simulation ended before the crypto search
window recovered. Passed in isolation immediately after, and the full
suite passed clean on re-run. The test drives a deadline loop over a
seeded end-to-end loss simulation — the load-sensitive shape
`Lane=Timing` exists for, but this test is not marked. Candidate fix:
either mark it `Lane=Timing` or make its quiescence wait
deadline-independent. Decide deliberately; do not just re-run until
green.
---
## #438 — Launcher crash-report bundles (WER dump capture + local bundle, no upload) ## #438 — Launcher crash-report bundles (WER dump capture + local bundle, no upload)
**Status:** OPEN — designed, ready to pick up as a launcher slice **Status:** OPEN — designed, ready to pick up as a launcher slice

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> /// </summary>
public event Action<PrivateUpdateVital.ParsedCurrent>? VitalCurrentUpdated; 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> /// <summary>
/// Phase 6 — server-broadcast PhysicsScript trigger. Fires when the /// Phase 6 — server-broadcast PhysicsScript trigger. Fires when the
/// server sends a <c>PlayScriptId</c> (opcode 0xF754) packet — /// server sends a <c>PlayScriptId</c> (opcode 0xF754) packet —
@ -2257,6 +2272,24 @@ public sealed class WorldSession : IDisposable
if (parsed is not null) if (parsed is not null)
VitalCurrentUpdated?.Invoke(parsed.Value); 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) else if (op == PublicUpdatePropertyInt.Opcode)
{ {
var p = PublicUpdatePropertyInt.TryParse(body); var p = PublicUpdatePropertyInt.TryParse(body);

View file

@ -444,15 +444,43 @@ public sealed class LocalPlayerState
} }
/// <summary> /// <summary>
/// Apply a primary-attribute update from PlayerDescription's /// Apply a primary-attribute update — from PlayerDescription's
/// attribute block (ids 1..=6). Vital ids (7..=9) here are silently /// attribute block at login, or (Campaign CA CA2, #431) from the live
/// dropped — feed them through <see cref="OnVitalUpdate"/> instead. /// <c>PrivateUpdateAttribute (0x02E3)</c> record after a raise. Vital
/// ids (7..=9) here are silently dropped — feed them through
/// <see cref="OnVitalUpdate"/> instead.
/// </summary> /// </summary>
/// <remarks>
/// Retail computes every derived value LIVE at inquiry
/// (<c>CACQualities::InqSkill @ 0x00592660</c>,
/// <c>InqAttribute2nd</c>), so applying the raw write is the whole
/// recompute — what remains is telling the observers. Retail's
/// notification carries no value; widgets re-pull
/// (<c>QualityRegistrar</c> pattern). Vitals maxima derive from
/// Endurance (health, stamina) and Self (mana), so those raises fan
/// out to the vital observers too — note ACE pushes a full Health
/// record after an Endurance raise but NOT a Stamina one, with the
/// explicit comment that the client is expected to refresh both; this
/// local fan-out is that expectation (research doc §4.1).
/// </remarks>
public void OnAttributeUpdate(uint atType, uint ranks, uint start, uint xp) public void OnAttributeUpdate(uint atType, uint ranks, uint start, uint xp)
{ {
if (AttributeIdToKind(atType) is not AttributeKind kind) return; if (AttributeIdToKind(atType) is not AttributeKind kind) return;
_attrs[kind] = new AttributeSnapshot(ranks, start, xp); _attrs[kind] = new AttributeSnapshot(ranks, start, xp);
AttributeChanged?.Invoke(kind); AttributeChanged?.Invoke(kind);
switch (kind)
{
case AttributeKind.Endurance:
Changed?.Invoke(VitalKind.Health);
Changed?.Invoke(VitalKind.Stamina);
break;
case AttributeKind.Self:
Changed?.Invoke(VitalKind.Mana);
break;
}
// Skill formula contributions derive from attribute currents —
// character-sheet consumers re-pull.
CharacterChanged?.Invoke();
} }
/// <summary>Replace the local player's top-level property snapshot from PlayerDescription.</summary> /// <summary>Replace the local player's top-level property snapshot from PlayerDescription.</summary>
@ -490,6 +518,33 @@ public sealed class LocalPlayerState
CharacterChanged?.Invoke(); CharacterChanged?.Invoke();
} }
/// <summary>
/// Campaign CA CA2 (#431): apply the live
/// <c>PrivateUpdateSkill (0x02DD)</c> record — the authoritative skill
/// state after raise / train / specialize / untrain / reset. The wire
/// record carries no attribute-formula contribution (retail computes it
/// live at inquiry), so the existing snapshot's <c>FormulaBonus</c> is
/// preserved — a skill update never changes attributes. A skill unseen
/// at login (fresh train) starts at 0 until the CA3 live computation
/// replaces the cached field entirely.
/// </summary>
public void OnSkillWireUpdate(
uint skillId,
uint ranks,
uint status,
uint xp,
uint init,
uint resistance,
double lastUsed)
{
uint formulaBonus = _skills.TryGetValue(skillId, out var prev)
? prev.FormulaBonus
: 0u;
_skills[skillId] = new SkillSnapshot(
skillId, ranks, status, xp, init, resistance, lastUsed, formulaBonus);
CharacterChanged?.Invoke();
}
/// <summary> /// <summary>
/// Optimistically apply a successful local attribute-raise action. /// Optimistically apply a successful local attribute-raise action.
/// The next server snapshot remains authoritative; this keeps UI state current /// The next server snapshot remains authoritative; this keeps UI state current

View file

@ -10,7 +10,7 @@ namespace AcDream.Core.Properties;
/// <summary> /// <summary>
/// AC's <c>PropertyString</c> property table — the numeric keys the server sends in /// AC's <c>PropertyString</c> property table — the numeric keys the server sends in
/// <c>PrivateUpdatePropertyString (0x02DD) / PublicUpdatePropertyString (0x02DE)</c> /// <c>PrivateUpdatePropertyString (0x02D5) / PublicUpdatePropertyString (0x02D6)</c>
/// and in the property bundles carried by <c>CreateObject</c> / <c>PlayerDescription</c> / /// and in the property bundles carried by <c>CreateObject</c> / <c>PlayerDescription</c> /
/// <c>IdentifyResponse</c>. The CLR payload for this table is <c>string</c>. /// <c>IdentifyResponse</c>. The CLR payload for this table is <c>string</c>.
/// ///

View file

@ -460,6 +460,28 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
vital => character.Character.LocalPlayer.OnVitalCurrent( vital => character.Character.LocalPlayer.OnVitalCurrent(
vital.VitalId, vital.VitalId,
vital.Current)); vital.Current));
// Campaign CA CA2 (#431): the authoritative post-raise records.
// Until these were routed, attributes and skills were stale
// from login's PlayerDescription until the next login.
Subscribe<PrivateUpdateAttribute.Parsed>(
h => session.AttributeUpdated += h,
h => session.AttributeUpdated -= h,
attr => character.Character.LocalPlayer.OnAttributeUpdate(
attr.AttributeId,
attr.Ranks,
attr.Start,
attr.Xp));
Subscribe<PrivateUpdateSkill.Parsed>(
h => session.SkillUpdated += h,
h => session.SkillUpdated -= h,
skill => character.Character.LocalPlayer.OnSkillWireUpdate(
skill.SkillId,
skill.Ranks,
skill.AdvancementClass,
skill.Xp,
skill.Init,
skill.Resistance,
skill.LastUsed));
if (Interlocked.CompareExchange(ref _lifecycleState, 2, 1) != 1) if (Interlocked.CompareExchange(ref _lifecycleState, 2, 1) != 1)
throw new ObjectDisposedException(nameof(LiveSessionEventRouter)); throw new ObjectDisposedException(nameof(LiveSessionEventRouter));

View 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)));
}
}

View file

@ -709,4 +709,59 @@ public sealed class LocalPlayerStateTests
spellId, "Test", "War Magic", 0u, 0u, "", 0f, 0, spellId, "Test", "War Magic", 0u, 0u, "", 0f, 0,
false, false, "", 0, 0, 0u, 0, false, false, true, false, false, "", 0, 0, 0u, 0, false, false, true,
0f, 0u, 0u, 0u, 0); 0f, 0u, 0u, 0u, 0);
// ------------------------------------------------------------------
// Campaign CA CA2 (#431): live post-raise updates.
// ------------------------------------------------------------------
[Fact]
public void AttributeUpdateFansOutToDerivedVitalObserversAndCharacterSheet()
{
// Retail pushes a full Health record after an Endurance raise but NOT
// a Stamina one, expecting the client to refresh both (ACE
// Player_Attributes.cs:56-58; research doc §4.1). The fan-out is the
// client-side half of that contract.
var s = new LocalPlayerState();
var vitalEvents = new List<LocalPlayerState.VitalKind>();
int attributeEvents = 0, characterEvents = 0;
s.Changed += k => vitalEvents.Add(k);
s.AttributeChanged += _ => attributeEvents++;
s.CharacterChanged += () => characterEvents++;
s.OnAttributeUpdate(atType: 2u /* Endurance */, ranks: 10u, start: 100u, xp: 500u);
Assert.Equal(
[LocalPlayerState.VitalKind.Health, LocalPlayerState.VitalKind.Stamina],
vitalEvents);
Assert.Equal(1, attributeEvents);
Assert.Equal(1, characterEvents);
vitalEvents.Clear();
s.OnAttributeUpdate(atType: 6u /* Self */, ranks: 5u, start: 100u, xp: 250u);
Assert.Equal([LocalPlayerState.VitalKind.Mana], vitalEvents);
vitalEvents.Clear();
s.OnAttributeUpdate(atType: 3u /* Quickness */, ranks: 1u, start: 100u, xp: 10u);
Assert.Empty(vitalEvents); // no vital derives from Quickness
Assert.Equal(3, attributeEvents);
}
[Fact]
public void SkillWireUpdatePreservesTheLoginFormulaBonus()
{
// The 0x02DD record carries no attribute contribution (retail
// computes it live at inquiry). Until CA3 makes the computation
// live, the wire update must not wipe the login-derived bonus.
var s = new LocalPlayerState();
s.OnSkillUpdate(skillId: 6u, ranks: 10u, status: 2u, xp: 100u,
init: 0u, resistance: 0u, lastUsed: 0d, formulaBonus: 120u);
s.OnSkillWireUpdate(skillId: 6u, ranks: 11u, status: 2u, xp: 2000u,
init: 0u, resistance: 0u, lastUsed: 5.0d);
var snap = s.Skills[6u];
Assert.Equal(11u, snap.Ranks);
Assert.Equal(2000u, snap.Xp);
Assert.Equal(120u, snap.FormulaBonus);
Assert.Equal(131u, snap.BaseLevel); // 120 formula + 0 init + 11 ranks
}
} }