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:
parent
30d294506c
commit
84b7d2d7cd
15 changed files with 391 additions and 29 deletions
|
|
@ -2537,7 +2537,8 @@ public sealed class GameWindow : IDisposable
|
|||
// D.5.4: ingest CreateObject into the object table (upsert) and wire Delete +
|
||||
// UiEffects live update. Wire BEFORE EntitySpawned += OnLiveEntitySpawned so
|
||||
// the table is populated before the render handler runs.
|
||||
AcDream.Core.Net.ObjectTableWiring.Wire(session, Objects, () => _playerServerGuid);
|
||||
AcDream.Core.Net.ObjectTableWiring.Wire(
|
||||
session, Objects, () => _playerServerGuid, LocalPlayer);
|
||||
AcDream.Core.Net.CombatStateWiring.Wire(session, Combat);
|
||||
_liveSession.EntitySpawned += OnLiveEntitySpawned;
|
||||
_liveSession.EntityDeleted += OnLiveEntityDeleted;
|
||||
|
|
|
|||
|
|
@ -297,7 +297,7 @@ public static class CharacterStatController
|
|||
|
||||
// TotalXpLabel (16px from dat) + TotalXp (16px from dat): pass null → keep dat font.
|
||||
LabelLeft(layout, contentPage, TotalXpLabelId, null, Body, static () => "Total Experience (XP):");
|
||||
Label(layout, contentPage, TotalXpId, null, Body, () => data().TotalXp.ToString("N0"));
|
||||
LabelRight(layout, contentPage, TotalXpId, null, Body, () => data().TotalXp.ToString("N0"));
|
||||
|
||||
// XP-to-level meter fill (gmStatManagementUI::UpdateExperience 0x004f0a70).
|
||||
// Fix 5: child elements 0x10000237 (label) and 0x10000238 (value) are now built by
|
||||
|
|
@ -1810,6 +1810,28 @@ public static class CharacterStatController
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>Right-justified numeric header value. The imported layout owns
|
||||
/// bounds/font; the controller owns runtime text and explicit justification.</summary>
|
||||
private static void LabelRight(
|
||||
ImportedLayout layout,
|
||||
UiElement? scope,
|
||||
uint id,
|
||||
UiDatFont? datFont,
|
||||
Vector4 color,
|
||||
Func<string> text)
|
||||
{
|
||||
if (FindTextByDatId(layout, scope, id) is UiText t)
|
||||
{
|
||||
if (datFont is not null) t.DatFont = datFont;
|
||||
t.Centered = false;
|
||||
t.RightAligned = true;
|
||||
t.OneLine = true;
|
||||
t.ClickThrough = true;
|
||||
t.Padding = 0f;
|
||||
t.LinesProvider = () => new[] { new UiText.Line(text(), color) };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Bind a directly-located <see cref="UiText"/> widget with a provider.
|
||||
/// Used when the widget was found by subtree walk rather than <c>FindElement</c>.
|
||||
/// Sets <c>Padding = 0</c> to prevent the scroll-clip from hiding text in small
|
||||
|
|
|
|||
35
src/AcDream.Core.Net/Messages/PrivateUpdatePropertyInt64.cs
Normal file
35
src/AcDream.Core.Net/Messages/PrivateUpdatePropertyInt64.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
using System.Buffers.Binary;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Inbound retail <c>PrivateUpdatePropertyInt64 (0x02CF)</c>: one signed
|
||||
/// 64-bit quality update for the local player. Total Experience (quality 1)
|
||||
/// and Available Experience (quality 2) use this message.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Retail: <c>CM_Qualities::DispatchUI_PrivateUpdateInt64 @ 0x006AEAD0</c>
|
||||
/// reads the sequence byte at +4, property at +5, and value at +9, then calls
|
||||
/// <c>ClientObjMaintSystem::Handle_Qualities__PrivateUpdateInt64 @ 0x00559000</c>.
|
||||
/// ACE <c>GameMessagePrivateUpdatePropertyInt64</c> and holtburger's
|
||||
/// <c>PrivateUpdatePropertyInt64Data</c> confirm the same field order.
|
||||
/// </remarks>
|
||||
public static class PrivateUpdatePropertyInt64
|
||||
{
|
||||
public const uint Opcode = 0x02CFu;
|
||||
public const int BodySize = 17; // opcode(4) + sequence(1) + property(4) + value(8)
|
||||
|
||||
public readonly record struct Parsed(uint Property, long Value);
|
||||
|
||||
/// <summary>Parse a complete 0x02CF body, or return null on mismatch/truncation.</summary>
|
||||
public static Parsed? TryParse(ReadOnlySpan<byte> body)
|
||||
{
|
||||
if (body.Length < BodySize) return null;
|
||||
if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null;
|
||||
|
||||
const int propertyOffset = 5; // opcode + one-byte quality sequence
|
||||
uint property = BinaryPrimitives.ReadUInt32LittleEndian(body[propertyOffset..]);
|
||||
long value = BinaryPrimitives.ReadInt64LittleEndian(body[(propertyOffset + 4)..]);
|
||||
return new Parsed(property, value);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Player;
|
||||
|
||||
namespace AcDream.Core.Net;
|
||||
|
||||
|
|
@ -6,8 +7,9 @@ namespace AcDream.Core.Net;
|
|||
/// Wires WorldSession GameMessage-level object events into the client object
|
||||
/// table: CreateObject (0xF745) = canonical merge-upsert, DeleteObject (0xF747)
|
||||
/// = evict, PublicUpdatePropertyInt (0x02CE) = live int apply, PrivateUpdatePropertyInt
|
||||
/// (0x02CD) = player int (burden), SetStackSize (0x0197) = stack count, InventoryRemoveObject
|
||||
/// (0x0024) = inventory-view removal.
|
||||
/// (0x02CD) = player int (burden), PrivateUpdatePropertyInt64 (0x02CF) = player
|
||||
/// 64-bit qualities (experience), SetStackSize (0x0197) = stack count,
|
||||
/// InventoryRemoveObject (0x0024) = inventory-view removal.
|
||||
/// Keeps object ingestion in Core.Net (pure data, no GL) and off GameWindow.
|
||||
/// Retail: ACCObjectMaint::CreateObject / DeleteObject (the weenie_object_table side).
|
||||
/// </summary>
|
||||
|
|
@ -18,7 +20,11 @@ public static class ObjectTableWiring
|
|||
/// on <paramref name="session"/>. Call this BEFORE the render handler subscribes
|
||||
/// to EntitySpawned so the table is populated before the render path runs.
|
||||
/// </summary>
|
||||
public static void Wire(WorldSession session, ClientObjectTable table, Func<uint>? playerGuid = null)
|
||||
public static void Wire(
|
||||
WorldSession session,
|
||||
ClientObjectTable table,
|
||||
Func<uint>? playerGuid = null,
|
||||
LocalPlayerState? localPlayer = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
ArgumentNullException.ThrowIfNull(table);
|
||||
|
|
@ -50,6 +56,14 @@ public static class ObjectTableWiring
|
|||
table.UpdateIntProperty(playerGuid(), u.Property, u.Value);
|
||||
};
|
||||
|
||||
// Retail's qualities system owns one local-player value and notifies every
|
||||
// registered panel. acdream currently exposes that value through two projections:
|
||||
// ClientObjectTable (retained UI) and LocalPlayerState (Core consumers/fallback).
|
||||
// Apply the authoritative 0x02CF update to both in this one wiring owner so they
|
||||
// cannot drift after the login PlayerDescription snapshot.
|
||||
session.PlayerInt64PropertyUpdated += u => ApplyPlayerInt64PropertyUpdate(
|
||||
table, localPlayer, playerGuid?.Invoke() ?? 0u, u);
|
||||
|
||||
// B-Wire: SetStackSize (0x0197) — update the object's stack count + value.
|
||||
session.StackSizeUpdated += u => table.UpdateStackSize(u.Guid, u.StackSize, u.Value);
|
||||
|
||||
|
|
@ -58,6 +72,18 @@ public static class ObjectTableWiring
|
|||
session.InventoryObjectRemoved += guid => table.Remove(guid);
|
||||
}
|
||||
|
||||
/// <summary>Shared application step kept internal for byte-to-state conformance tests.</summary>
|
||||
internal static void ApplyPlayerInt64PropertyUpdate(
|
||||
ClientObjectTable table,
|
||||
LocalPlayerState? localPlayer,
|
||||
uint playerGuid,
|
||||
WorldSession.PlayerInt64PropertyUpdate update)
|
||||
{
|
||||
if (playerGuid != 0u)
|
||||
table.UpdateInt64Property(playerGuid, update.Property, update.Value);
|
||||
localPlayer?.OnInt64PropertyUpdate(update.Property, update.Value);
|
||||
}
|
||||
|
||||
/// <summary>Translate the wire spawn into the table's merge patch.</summary>
|
||||
public static WeenieData ToWeenieData(WorldSession.EntitySpawn s) => new(
|
||||
Guid: s.Guid,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -327,6 +327,18 @@ public sealed class LocalPlayerState
|
|||
CharacterChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply one authoritative signed 64-bit player-quality update and notify
|
||||
/// character-sheet consumers. Retail routes 0x02CF through
|
||||
/// <c>Handle_Qualities__PrivateUpdateInt64 @ 0x00559000</c>; Total XP (1)
|
||||
/// and Available XP (2) are the character-window values carried here.
|
||||
/// </summary>
|
||||
public void OnInt64PropertyUpdate(uint propertyId, long value)
|
||||
{
|
||||
_properties.Int64s[propertyId] = value;
|
||||
CharacterChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>Apply or replace one PlayerDescription skill entry.</summary>
|
||||
public void OnSkillUpdate(
|
||||
uint skillId,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue