fix(ui): port live experience quality updates (#217)

Parse retail PrivateUpdatePropertyInt64 and route authoritative Total/Available Experience through both local-player projections so Attributes, the level meter, and Skills refresh together. Preserve the existing retail XP curve and right-align the Total XP value.

Close the user-confirmed item-give gate for #216 and record the named-retail/ACE/holtburger conformance evidence.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-13 20:43:48 +02:00
parent 30d294506c
commit 84b7d2d7cd
15 changed files with 391 additions and 29 deletions

View file

@ -294,6 +294,15 @@ public sealed class WorldSession : IDisposable
/// PropertyInt updated on the player. B-Wire routes EncumbranceVal (5) to the burden bar.</summary>
public event Action<PlayerIntPropertyUpdate>? PlayerIntPropertyUpdated;
/// <summary>Payload for <see cref="PlayerInt64PropertyUpdated"/>: a signed
/// 64-bit quality change on the player's own object. Retail sends Total XP
/// (1) and Available XP (2) through PrivateUpdatePropertyInt64 (0x02CF).</summary>
public readonly record struct PlayerInt64PropertyUpdate(uint Property, long Value);
/// <summary>Fires after parsing retail PrivateUpdatePropertyInt64 (0x02CF).
/// The wire carries no guid because the local player is implicit.</summary>
public event Action<PlayerInt64PropertyUpdate>? PlayerInt64PropertyUpdated;
/// <summary>Payload for <see cref="StackSizeUpdated"/>: SetStackSize (0x0197) — a stack's
/// count + value after a merge / split.</summary>
public readonly record struct StackSizeUpdate(uint Guid, int StackSize, int Value);
@ -1078,6 +1087,15 @@ public sealed class WorldSession : IDisposable
PlayerIntPropertyUpdated?.Invoke(
new PlayerIntPropertyUpdate(p.Value.Property, p.Value.Value));
}
else if (op == PrivateUpdatePropertyInt64.Opcode)
{
// Retail CM_Qualities::DispatchUI_PrivateUpdateInt64 @ 0x006AEAD0.
// TotalExperience (1) and AvailableExperience (2) both arrive here.
var p = PrivateUpdatePropertyInt64.TryParse(body);
if (p is not null)
PlayerInt64PropertyUpdated?.Invoke(
new PlayerInt64PropertyUpdate(p.Value.Property, p.Value.Value));
}
else if (op == SetStackSize.Opcode)
{
var p = SetStackSize.TryParse(body);