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

@ -0,0 +1,49 @@
using System.Buffers.Binary;
using AcDream.Core.Net.Messages;
namespace AcDream.Core.Net.Tests.Messages;
/// <summary>
/// Golden layout from retail
/// <c>CM_Qualities::DispatchUI_PrivateUpdateInt64 @ 0x006AEAD0</c>,
/// cross-checked against ACE and holtburger.
/// </summary>
public sealed class PrivateUpdatePropertyInt64Tests
{
private static byte[] Build(
uint property,
long value,
byte sequence = 1,
uint opcode = PrivateUpdatePropertyInt64.Opcode)
{
var body = new byte[PrivateUpdatePropertyInt64.BodySize];
BinaryPrimitives.WriteUInt32LittleEndian(body, opcode);
body[4] = sequence;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(5), property);
BinaryPrimitives.WriteInt64LittleEndian(body.AsSpan(9), value);
return body;
}
[Theory]
[InlineData(1u, 1_234_567_890L)]
[InlineData(2u, 987_654_321L)]
[InlineData(6u, -1L)]
public void TryParse_RetailBody_ReturnsSignedPropertyValue(uint property, long value)
{
var parsed = PrivateUpdatePropertyInt64.TryParse(Build(property, value, sequence: 0x7B));
Assert.NotNull(parsed);
Assert.Equal(property, parsed.Value.Property);
Assert.Equal(value, parsed.Value.Value);
}
[Fact]
public void TryParse_WrongOpcode_ReturnsNull()
=> Assert.Null(PrivateUpdatePropertyInt64.TryParse(
Build(1u, 100L, opcode: 0x02D0u)));
[Fact]
public void TryParse_Truncated_ReturnsNull()
=> Assert.Null(PrivateUpdatePropertyInt64.TryParse(
new byte[PrivateUpdatePropertyInt64.BodySize - 1]));
}

View file

@ -1,6 +1,7 @@
using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Player;
namespace AcDream.Core.Net.Tests;
@ -116,6 +117,37 @@ public sealed class ObjectTableWiringTests
Assert.Equal(0x50000001u, d.PetOwnerId);
}
[Fact]
public void ApplyPlayerInt64PropertyUpdate_SynchronizesBothPlayerProjections()
{
const uint playerGuid = 0x50000001u;
var table = new ClientObjectTable();
var playerObject = new ClientObject { ObjectId = playerGuid };
playerObject.Properties.Int64s[2u] = 0L;
table.AddOrUpdate(playerObject);
var localPlayer = new LocalPlayerState();
var loginProperties = new PropertyBundle();
loginProperties.Int64s[2u] = 0L;
localPlayer.OnProperties(loginProperties);
int tableChanges = 0;
int playerChanges = 0;
table.ObjectUpdated += _ => tableChanges++;
localPlayer.CharacterChanged += () => playerChanges++;
ObjectTableWiring.ApplyPlayerInt64PropertyUpdate(
table,
localPlayer,
playerGuid,
new WorldSession.PlayerInt64PropertyUpdate(2u, 75_000L));
Assert.Equal(75_000L, table.Get(playerGuid)!.Properties.GetInt64(2u));
Assert.Equal(75_000L, localPlayer.Properties.GetInt64(2u));
Assert.Equal(1, tableChanges);
Assert.Equal(1, playerChanges);
}
// -------------------------------------------------------------------------
// The handler that ObjectTableWiring.Wire subscribes to session.EntityDeleted.
// Testing it directly avoids needing a live WorldSession (UDP socket).

View file

@ -90,6 +90,31 @@ public sealed class PlayerDescriptionParserTests
Assert.Empty(p.Value.Attributes);
}
[Fact]
public void TryParse_LoginInt64Table_PopulatesTotalAndAvailableExperience()
{
// ACE GameEventPlayerDescription: flags + weenie type, then the gated
// Int64 hash-table (u16 count/u16 buckets + key/i64 pairs), then vector header.
byte[] body = new byte[44];
int pos = 0;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(pos), 0x0080u); pos += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(pos), 1u); pos += 4;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(pos), 2); pos += 2;
BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(pos), 64); pos += 2;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(pos), 1u); pos += 4;
BinaryPrimitives.WriteInt64LittleEndian(body.AsSpan(pos), 1_234_567_890L); pos += 8;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(pos), 2u); pos += 4;
BinaryPrimitives.WriteInt64LittleEndian(body.AsSpan(pos), 75_000L); pos += 8;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(pos), 0u); pos += 4;
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(pos), 0u);
var parsed = PlayerDescriptionParser.TryParse(body);
Assert.NotNull(parsed);
Assert.Equal(1_234_567_890L, parsed.Value.Properties.GetInt64(1u));
Assert.Equal(75_000L, parsed.Value.Properties.GetInt64(2u));
}
[Fact]
public void TryParse_AttributeBlock_PopulatesAllNineEntries()
{