From 65430d4c7c8fbe84b95b492d1f3767c11f1b9aca Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 13:38:50 +0200 Subject: [PATCH] feat(net) Campaign CA CA2 #431: parse the inbound attribute/skill update family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/ISSUES.md | 20 +++ .../Messages/PrivateUpdateAttribute.cs | 69 +++++++++ .../Messages/PrivateUpdateSkill.cs | 85 ++++++++++++ src/AcDream.Core.Net/WorldSession.cs | 33 +++++ src/AcDream.Core/Player/LocalPlayerState.cs | 61 +++++++- src/AcDream.Core/Properties/PropertyString.cs | 2 +- .../Session/LiveSessionEventRouter.cs | 22 +++ .../PrivateUpdateAttributeSkillTests.cs | 131 ++++++++++++++++++ .../Player/LocalPlayerStateTests.cs | 55 ++++++++ 9 files changed, 474 insertions(+), 4 deletions(-) create mode 100644 src/AcDream.Core.Net/Messages/PrivateUpdateAttribute.cs create mode 100644 src/AcDream.Core.Net/Messages/PrivateUpdateSkill.cs create mode 100644 tests/AcDream.Core.Net.Tests/PrivateUpdateAttributeSkillTests.cs 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 + } }