diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 6e7f66f7..131df0a0 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -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.
- 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)
**Status:** OPEN — designed, ready to pick up as a launcher slice
diff --git a/src/AcDream.Core.Net/Messages/PrivateUpdateAttribute.cs b/src/AcDream.Core.Net/Messages/PrivateUpdateAttribute.cs
new file mode 100644
index 00000000..7c61f673
--- /dev/null
+++ b/src/AcDream.Core.Net/Messages/PrivateUpdateAttribute.cs
@@ -0,0 +1,69 @@
+using System;
+using System.Buffers.Binary;
+
+namespace AcDream.Core.Net.Messages;
+
+///
+/// Inbound primary-attribute update GameMessage for the local player
+/// (0x02E3) — the server's authoritative answer to a RaiseAttribute
+/// action (and any other server-side attribute change). A standalone
+/// GameMessage like , NOT a 0xF7B0
+/// GameEvent.
+///
+///
+/// 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.
+///
+///
+///
+/// Wire layout — three-source agreement (ACE
+/// GameMessagePrivateUpdateAttribute.cs:8-16; Chorizite
+/// AttributeInfo.generated.cs:26-54; holtburger
+/// player/types.rs:22-53 UpdateAttribute<false>), full
+/// citations in
+/// docs/research/2026-08-24-advancement-wire-and-recompute.md §2.5:
+///
+///
+/// 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
+///
+///
+public static class PrivateUpdateAttribute
+{
+ public const uint Opcode = 0x02E3u;
+
+ /// Parsed attribute update.
+ public readonly record struct Parsed(
+ byte Sequence,
+ uint AttributeId,
+ uint Ranks,
+ uint Start,
+ uint Xp);
+
+ ///
+ /// Parse a raw PrivateUpdateAttribute (0x02E3) body. Returns
+ /// null on opcode mismatch or truncation.
+ ///
+ public static Parsed? TryParse(ReadOnlySpan 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);
+ }
+}
diff --git a/src/AcDream.Core.Net/Messages/PrivateUpdateSkill.cs b/src/AcDream.Core.Net/Messages/PrivateUpdateSkill.cs
new file mode 100644
index 00000000..0d7f9a9d
--- /dev/null
+++ b/src/AcDream.Core.Net/Messages/PrivateUpdateSkill.cs
@@ -0,0 +1,85 @@
+using System;
+using System.Buffers.Binary;
+
+namespace AcDream.Core.Net.Messages;
+
+///
+/// Inbound skill update GameMessage for the local player
+/// (0x02DD) — the server's authoritative answer to RaiseSkill /
+/// TrainSkill / specialize / untrain / reset. A standalone GameMessage
+/// like , NOT a 0xF7B0 GameEvent.
+///
+///
+/// Campaign CA slice CA2 (#431). NOTE the opcode-neighborhood trap this
+/// slice also corrected: 0x02DD is PrivateUpdateSkill, not
+/// PrivateUpdatePropertyString (which is 0x02D5) — ACE
+/// GameMessageOpcode.cs:21,29. The related ranks-only
+/// PrivateUpdateSkillLevel (0x02DF) has NO producer anywhere in
+/// ACE (verified 2026-08-24) and is deliberately not parsed.
+///
+///
+///
+/// Wire layout — three-source agreement (ACE
+/// GameMessagePrivateUpdateSkill.cs:8-24; Chorizite
+/// Skill.generated.cs:22-86; holtburger
+/// player/types.rs:71-131 with the golden fixture at
+/// :279-293 confirming adjustPP=1 on real captures), full
+/// citations in
+/// docs/research/2026-08-24-advancement-wire-and-recompute.md §2.8:
+///
+///
+/// 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
+///
+///
+public static class PrivateUpdateSkill
+{
+ public const uint Opcode = 0x02DDu;
+
+ /// Parsed skill update. Ranks widened from the wire's u16.
+ public readonly record struct Parsed(
+ byte Sequence,
+ uint SkillId,
+ uint Ranks,
+ ushort AdjustPP,
+ uint AdvancementClass,
+ uint Xp,
+ uint Init,
+ uint Resistance,
+ double LastUsed);
+
+ ///
+ /// Parse a raw PrivateUpdateSkill (0x02DD) body. Returns
+ /// null on opcode mismatch or truncation.
+ ///
+ public static Parsed? TryParse(ReadOnlySpan 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);
+ }
+}
diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs
index 2b83fc50..60d378fe 100644
--- a/src/AcDream.Core.Net/WorldSession.cs
+++ b/src/AcDream.Core.Net/WorldSession.cs
@@ -500,6 +500,21 @@ public sealed class WorldSession : IDisposable
///
public event Action? VitalCurrentUpdated;
+ ///
+ /// Campaign CA CA2 (#431): fires when a
+ /// PrivateUpdateAttribute (0x02E3) arrives — the authoritative
+ /// primary-attribute record after a raise. Subscribers typically feed
+ /// .
+ ///
+ public event Action? AttributeUpdated;
+
+ ///
+ /// Campaign CA CA2 (#431): fires when a
+ /// PrivateUpdateSkill (0x02DD) arrives — the authoritative skill
+ /// record after raise / train / specialize / untrain / reset.
+ ///
+ public event Action? SkillUpdated;
+
///
/// Phase 6 — server-broadcast PhysicsScript trigger. Fires when the
/// server sends a PlayScriptId (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);
diff --git a/src/AcDream.Core/Player/LocalPlayerState.cs b/src/AcDream.Core/Player/LocalPlayerState.cs
index b122baf0..d15b6366 100644
--- a/src/AcDream.Core/Player/LocalPlayerState.cs
+++ b/src/AcDream.Core/Player/LocalPlayerState.cs
@@ -444,15 +444,43 @@ public sealed class LocalPlayerState
}
///
- /// Apply a primary-attribute update from PlayerDescription's
- /// attribute block (ids 1..=6). Vital ids (7..=9) here are silently
- /// dropped — feed them through instead.
+ /// Apply a primary-attribute update — from PlayerDescription's
+ /// attribute block at login, or (Campaign CA CA2, #431) from the live
+ /// PrivateUpdateAttribute (0x02E3) record after a raise. Vital
+ /// ids (7..=9) here are silently dropped — feed them through
+ /// instead.
///
+ ///
+ /// Retail computes every derived value LIVE at inquiry
+ /// (CACQualities::InqSkill @ 0x00592660,
+ /// InqAttribute2nd), so applying the raw write is the whole
+ /// recompute — what remains is telling the observers. Retail's
+ /// notification carries no value; widgets re-pull
+ /// (QualityRegistrar 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).
+ ///
public void OnAttributeUpdate(uint atType, uint ranks, uint start, uint xp)
{
if (AttributeIdToKind(atType) is not AttributeKind kind) return;
_attrs[kind] = new AttributeSnapshot(ranks, start, xp);
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();
}
/// Replace the local player's top-level property snapshot from PlayerDescription.
@@ -490,6 +518,33 @@ public sealed class LocalPlayerState
CharacterChanged?.Invoke();
}
+ ///
+ /// Campaign CA CA2 (#431): apply the live
+ /// PrivateUpdateSkill (0x02DD) 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 FormulaBonus 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.
+ ///
+ 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();
+ }
+
///
/// Optimistically apply a successful local attribute-raise action.
/// The next server snapshot remains authoritative; this keeps UI state current
diff --git a/src/AcDream.Core/Properties/PropertyString.cs b/src/AcDream.Core/Properties/PropertyString.cs
index 0e2f0b03..d64d5cfc 100644
--- a/src/AcDream.Core/Properties/PropertyString.cs
+++ b/src/AcDream.Core/Properties/PropertyString.cs
@@ -10,7 +10,7 @@ namespace AcDream.Core.Properties;
///
/// AC's PropertyString property table — the numeric keys the server sends in
-/// PrivateUpdatePropertyString (0x02DD) / PublicUpdatePropertyString (0x02DE)
+/// PrivateUpdatePropertyString (0x02D5) / PublicUpdatePropertyString (0x02D6)
/// and in the property bundles carried by CreateObject / PlayerDescription /
/// IdentifyResponse. The CLR payload for this table is string.
///
diff --git a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs
index f1caafdf..a633e69e 100644
--- a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs
+++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs
@@ -460,6 +460,28 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
vital => character.Character.LocalPlayer.OnVitalCurrent(
vital.VitalId,
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(
+ h => session.AttributeUpdated += h,
+ h => session.AttributeUpdated -= h,
+ attr => character.Character.LocalPlayer.OnAttributeUpdate(
+ attr.AttributeId,
+ attr.Ranks,
+ attr.Start,
+ attr.Xp));
+ Subscribe(
+ 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)
throw new ObjectDisposedException(nameof(LiveSessionEventRouter));
diff --git a/tests/AcDream.Core.Net.Tests/PrivateUpdateAttributeSkillTests.cs b/tests/AcDream.Core.Net.Tests/PrivateUpdateAttributeSkillTests.cs
new file mode 100644
index 00000000..1b2a014a
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/PrivateUpdateAttributeSkillTests.cs
@@ -0,0 +1,131 @@
+using System.Buffers.Binary;
+using AcDream.Core.Net.Messages;
+
+namespace AcDream.Core.Net.Tests;
+
+///
+/// Campaign CA CA2 (#431) wire-format tests for
+/// and
+/// . Layouts carry three-source
+/// agreement (ACE producer + Chorizite generated type + holtburger
+/// implementation); the skill round-trip mirrors holtburger's golden
+/// fixture (types.rs:279-293 — ranks 50, adjustPP 1, status 3,
+/// xp 1000, init 10, resistance 0, lastUsed 0.0). Full citations:
+/// docs/research/2026-08-24-advancement-wire-and-recompute.md
+/// §2.5 / §2.8.
+///
+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)));
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs b/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs
index 476ecf19..468e41ae 100644
--- a/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs
+++ b/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs
@@ -709,4 +709,59 @@ public sealed class LocalPlayerStateTests
spellId, "Test", "War Magic", 0u, 0u, "", 0f, 0,
false, false, "", 0, 0, 0u, 0, false, false, true,
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();
+ 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
+ }
}