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
|
|
@ -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
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
}
|
||||
85
src/AcDream.Core.Net/Messages/PrivateUpdateSkill.cs
Normal file
85
src/AcDream.Core.Net/Messages/PrivateUpdateSkill.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -500,6 +500,21 @@ public sealed class WorldSession : IDisposable
|
|||
/// </summary>
|
||||
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>
|
||||
/// Phase 6 — server-broadcast PhysicsScript trigger. Fires when the
|
||||
/// server sends a <c>PlayScriptId</c> (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);
|
||||
|
|
|
|||
|
|
@ -444,15 +444,43 @@ public sealed class LocalPlayerState
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply a primary-attribute update from PlayerDescription's
|
||||
/// attribute block (ids 1..=6). Vital ids (7..=9) here are silently
|
||||
/// dropped — feed them through <see cref="OnVitalUpdate"/> instead.
|
||||
/// Apply a primary-attribute update — from PlayerDescription's
|
||||
/// attribute block at login, or (Campaign CA CA2, #431) from the live
|
||||
/// <c>PrivateUpdateAttribute (0x02E3)</c> record after a raise. Vital
|
||||
/// ids (7..=9) here are silently dropped — feed them through
|
||||
/// <see cref="OnVitalUpdate"/> instead.
|
||||
/// </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)
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>Replace the local player's top-level property snapshot from PlayerDescription.</summary>
|
||||
|
|
@ -490,6 +518,33 @@ public sealed class LocalPlayerState
|
|||
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>
|
||||
/// Optimistically apply a successful local attribute-raise action.
|
||||
/// The next server snapshot remains authoritative; this keeps UI state current
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ namespace AcDream.Core.Properties;
|
|||
|
||||
/// <summary>
|
||||
/// 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> /
|
||||
/// <c>IdentifyResponse</c>. The CLR payload for this table is <c>string</c>.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -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<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)
|
||||
throw new ObjectDisposedException(nameof(LiveSessionEventRouter));
|
||||
|
|
|
|||
131
tests/AcDream.Core.Net.Tests/PrivateUpdateAttributeSkillTests.cs
Normal file
131
tests/AcDream.Core.Net.Tests/PrivateUpdateAttributeSkillTests.cs
Normal 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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue