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

@ -44,38 +44,40 @@ Copy this block when adding a new issue:
---
## #216 — Inventory drags into the 3-D world could only drop items
## #217 — Character windows did not receive live 64-bit experience updates
**Status:** IN-PROGRESS
**Status:** IN-PROGRESS — implementation complete; connected visual gate pending
**Severity:** HIGH
**Filed:** 2026-07-13
**Component:** retained UI / inventory / interaction / net
**Component:** retained UI / character sheet / net / player state
**Description:** Releasing a physical inventory item over an NPC always sent
the ground-drop path. A starter-dungeon character therefore could not hand the
exit token to the exit NPC and could not complete the dungeon.
**Description:** The Attributes window always displayed Total Experience as 0
and an empty level-progress meter after the player earned XP. The Skills footer
also displayed Unassigned Experience as 0, preventing an accurate raise-cost
decision. The Total Experience number was centered instead of right-aligned.
**Root cause / status:** Implementation complete; connected live gate pending.
The exact `AttemptPlaceIn3D` policy and the `0x00CD`
packet builder already existed, but `GameWindow.OnUiDragReleasedOutside`
discarded the release coordinates and always called `DropToWorld`, hard-coding
`TargetId=0`. The release path now world-picks at the actual release point,
the controller executes its existing `GiveToTarget` action without optimistic
mutation, `WorldSession` sends the exact request, and PlayerDescription drives
the retail player secure-trade preference.
**Root cause / status:** acdream parsed the neighboring private/public Int32
quality messages (`0x02CD`/`0x02CE`) but omitted retail
`PrivateUpdatePropertyInt64 (0x02CF)`. Both TotalExperience (quality 1) and
AvailableExperience (quality 2) use that message. The exact parser/event/state
path is now ported, both local-player projections are synchronized in one
wiring owner, the existing retail XP-band formula receives live data, and only
the value element `0x10000235` is explicitly right-aligned.
**Files:** `src/AcDream.App/UI/ItemInteractionController.cs`;
`src/AcDream.App/Rendering/GameWindow.cs`;
`src/AcDream.Core.Net/WorldSession.cs`.
**Files:** `src/AcDream.Core.Net/Messages/PrivateUpdatePropertyInt64.cs`;
`src/AcDream.Core.Net/WorldSession.cs`;
`src/AcDream.Core.Net/ObjectTableWiring.cs`;
`src/AcDream.Core/Player/LocalPlayerState.cs`;
`src/AcDream.App/UI/Layout/CharacterStatController.cs`.
**Research:** `docs/research/2026-07-13-retail-give-item-pseudocode.md`;
`ItemHolder::AttemptPlaceIn3D @ 0x00588600`;
`ACCWeenieObject::UIAttemptGive @ 0x0058D620`;
`CM_Inventory::Event_GiveObjectRequest @ 0x006ABB00`.
**Research:** `docs/research/2026-07-13-retail-experience-update-pseudocode.md`;
`CM_Qualities::DispatchUI_PrivateUpdateInt64 @ 0x006AEAD0`;
`ClientObjMaintSystem::Handle_Qualities__PrivateUpdateInt64 @ 0x00559000`;
`gmStatManagementUI::UpdateExperience @ 0x004F0A70`.
**Acceptance:** Drag the starter-dungeon exit token from the backpack onto the
exit NPC. ACE accepts the token, the inventory removes/decrements it only from
the authoritative server response, and the quest teleport exits the dungeon.
**Acceptance:** Earn XP while logged in. Attributes immediately shows the new
right-aligned total and red progress fill; Skills immediately shows the same
server-authoritative unassigned XP available for raises.
---
@ -8225,6 +8227,18 @@ Fixed by the A7 lighting rework, specifically A7 Fix D (`0980bea4`, `cf627933`,
**Status:** DONE (2026-07-09, investigated this session).
The described mechanism (live entities collapsing to one root-centered cylinder) no longer exists. `RegisterLiveEntityCollision` (`GameWindow.cs:4244`) now builds a multi-shape list via `ShadowShapeBuilder.FromSetup` — one shape per CylSphere, Sphere, and BSP-bearing Part — and registers via `RegisterMultiPart` (A6.P4 Task 7, `ca9341c2`). The retail `CSphere` and `CCylSphere` collision families were ported verbatim in `78e57581` (sphere primitive), `6ab26989` (fix #172, CCylSphere family), and `96ae2740` (fix #182, CSphere family, retires TS-45). The remaining `setup.Radius` single-cylinder path is now a deliberately-audited fallback used only when no other shape data exists. `tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs` covers both a multi-shape object and a live-creature case.
---
## #216 — Inventory drags into the 3-D world could only drop items
**Status:** DONE (2026-07-13, `30d29450`, user-confirmed connected gate).
Dragging the starter-dungeon exit token onto its NPC now world-picks at the
actual release point, executes the retail `GiveToTarget` path without optimistic
inventory mutation, receives ACE's authoritative removal, and completes the
quest teleport out of the dungeon.
---
<!--
Example:

View file

@ -504,6 +504,7 @@ behavior. Estimated 1726 days focused work, 35 weeks calendar.
- **M2 basic retail combat bar implemented 2026-07-11; corrective live visual gate pending.** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the authored wider centered dark-red child range (accepted IA-20), full-width left-to-right bright live attack-charge feedback, exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness seams.
- **Retail client command families implemented 2026-07-13; corrective live gate pending.** One shared typed catalog now separates retail client actions from ACE administrator commands for both chat backends. Named-decomp ports cover recall/house/PK travel; age/birth; framerate, lock, version, location, corpse, and die confirmation; clear plus named/automatic UI layouts; AFK/consent; emotes; friends; squelch/filter/message types; and fill-components. App owns execution, Core owns authoritative friends/squelch state, Core.Net owns exact UIQueue/ControlQueue packets. Confirmation reuse now ports retail `DialogFactory` contexts, queue groups/priority, fresh DAT roots, property results, callbacks/close notices, and server abort handling; `/die`, gameplay request tuples, and guarded item-use prompts share its type-1 LayoutDesc `0x2100003C` presenter. The first live gate passed the family except for raw suicide-success code `0x004A` and the title-bar-only FPS presentation; the correction maps retail's text and mounts SmartBox element `0x10000047` with live two-decimal `FPS`/`DEG`. #L.6 is closed; TS-31/TS-47 are narrowed. Research: `docs/research/2026-07-13-retail-client-command-families-pseudocode.md`, `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`.
- **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158.
- **✓ CORRECTIVE PORT — live character experience qualities** (2026-07-13, #217; connected gate pending). Added retail `PrivateUpdatePropertyInt64 (0x02CF)` so Total Experience, the level-progress meter, and Skills' Unassigned Experience refresh from the same authoritative server update; Total XP value alignment now matches the authored right-justified layout.
- **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository``ClientObjectTable` / `ItemInstance``ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green.
- **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars.
- **✓ D.5.3 — Toolbar selected-object display (issue #141) SHIPPED.** Exact health/player/pet/Free-PK policy, selected-item mana (`0x100001A2`), stack entry (`0x100001A3`), stack slider (`0x100001A4`), formatted count/name, shared split quantity, click-to-use, peace/war, health/name, and paperdoll self/upper-item selection all passed live by 2026-07-11. Exact raw `ShortCutData` preservation is already shipped in Wave 4.2; the eight `favorite_spells_` lists belong to the separate spellbook/spell-bar phase.

View file

@ -0,0 +1,81 @@
# Retail experience-update pipeline pseudocode
**Date:** 2026-07-13
**Scope:** live Total Experience, Unassigned Experience, and character-level XP meter
## Retail oracle
- `CM_Qualities::DispatchUI_PrivateUpdateInt64 @ 0x006AEAD0`
validates opcode `0x02CF`, then reads `sequence:u8`, `property:u32`, and
`value:i64` from offsets 4, 5, and 9.
- `ClientObjMaintSystem::Handle_Qualities__PrivateUpdateInt64 @ 0x00559000`
applies the property to the local player's qualities through the common
`UpdateStat<Int64_QualityType, int64_t>` path.
- `gmStatManagementUI::PostInit @ 0x004EFD90` registers the panel for player
`StatType::Int64` qualities 1 (TotalExperience), 2 (AvailableExperience),
6 (AvailableLuminance), and 7 (MaximumLuminance).
- `gmStatManagementUI::Update @ 0x004F0020` calls `UpdateExperience` whenever
the registered player qualities refresh.
- `gmStatManagementUI::UpdateExperience @ 0x004F0A70` reads Int64 quality 1,
reads Int quality `0x19` (level), derives the current and next cumulative-XP
thresholds, writes the total-XP and XP-to-next text, and sets meter attribute
`0x69` to `(totalXp - currentThreshold) / (nextThreshold - currentThreshold)`.
The named top-level dispatch table routes `0x02CD` to private Int32,
`0x02CE` to public Int32, `0x02CF` to private Int64, and `0x02D0` to public
Int64. acdream already handled the first two and was missing `0x02CF`.
## Cross-reference
- ACE `GameMessagePrivateUpdatePropertyInt64` writes the same 17-byte body:
opcode, one-byte sequence, property id, signed 64-bit value.
- ACE marks `PropertyInt64.TotalExperience` (1) and
`PropertyInt64.AvailableExperience` (2) `SendOnLogin`, and
`GameEventPlayerDescription` includes both in the login Int64 table.
- holtburger
`crates/holtburger-protocol/src/messages/object/messages/properties.rs`
defines `PrivateUpdatePropertyInt64Data = UpdatePropertyInt64<false>`; its
generic private-property unpacker reads `u8 sequence`, no guid, `u32
property`, then `i64 value`.
## Faithful pseudocode
```text
on top-level game message(body):
if body.opcode == 0x02CF:
require body.length >= 17
sequence = body.u8_at(4) // transport ordering byte; latest value wins
property = body.u32_at(5)
value = body.i64_at(9)
player.qualities.int64[property] = value
notify registered quality observers
on character-stat panel update(player.qualities):
totalXp = qualities.int64[1]
level = qualities.int32[0x19]
current = ExperienceToLevel(level)
next = ExperienceToLevel(level + 1)
totalXpText = format(totalXp)
xpToNextText = format(max(0, next - totalXp))
xpMeter = next > current
? clamp((totalXp - current) / (next - current), 0, 1)
: 0
on skill panel update(player.qualities):
unassignedExperienceText = format(qualities.int64[2])
```
## Port shape
1. Parse `0x02CF` as an exact typed top-level message.
2. Publish a typed `PlayerInt64PropertyUpdated` event from `WorldSession`.
3. Apply the update to both existing local-player projections
(`ClientObjectTable` and `LocalPlayerState`) so every retained UI consumer
observes the same authoritative server value.
4. Keep the existing ExperienceTable calculation; it already matches
`UpdateExperience` and was only starved of live data.
5. Preserve the imported retail layout and explicitly right-align only the
Total Experience value element (`0x10000235`); its caption remains left
aligned.

View file

@ -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;

View file

@ -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

View 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);
}
}

View file

@ -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,

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);

View file

@ -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,

View file

@ -99,6 +99,23 @@ public sealed class CharacterSheetProviderTests
Assert.Equal(90L, sheet.AttributeRaise10Costs[0]);
}
[Fact]
public void BuildSheet_AfterLiveInt64Updates_RefreshesBothXpWindowsAndMeter()
{
var h = new Harness();
h.AddPlayerObject(unassignedXp: 0L);
Assert.True(h.Table.UpdateInt64Property(PlayerGuid, 1u, 200L));
Assert.True(h.Table.UpdateInt64Property(PlayerGuid, 2u, 75L));
var sheet = h.Provider.BuildSheet();
Assert.Equal(200L, sheet.TotalXp);
Assert.Equal(75L, sheet.UnassignedXp);
Assert.Equal(50L, sheet.XpToNextLevel);
Assert.Equal(2f / 3f, sheet.XpFraction, precision: 4);
}
[Fact]
public void BuildSheet_Skills_MapsAdvancementAndCurveCosts()
{

View file

@ -1366,6 +1366,19 @@ public class CharacterStatControllerTests
Assert.False(lbl.RightAligned, "TotalXpLabel must be left-justified (RightAligned=false)");
}
[Fact]
public void Bind_TotalXpValue_IsRightAligned()
{
var value = new UiText { Centered = true };
var layout = Fake((CharacterStatController.TotalXpId, value));
CharacterStatController.Bind(layout, SampleData.SampleCharacter);
Assert.Equal((1_250_000_000L).ToString("N0"), value.LinesProvider()[0].Text);
Assert.False(value.Centered);
Assert.True(value.RightAligned);
}
// ── Polish Commit 1: name white, Infinity!, white footer title ─────────────
[Fact]

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()
{

View file

@ -262,6 +262,22 @@ public sealed class LocalPlayerStateTests
Assert.Equal(1_234_567_890L, s.Properties.GetInt64(1u));
}
[Fact]
public void OnInt64PropertyUpdate_ReplacesValueAndFiresCharacterChanged()
{
var s = new LocalPlayerState();
var props = new PropertyBundle();
props.Int64s[1u] = 100L;
s.OnProperties(props);
int changed = 0;
s.CharacterChanged += () => changed++;
s.OnInt64PropertyUpdate(1u, 1_234_567_890L);
Assert.Equal(1_234_567_890L, s.Properties.GetInt64(1u));
Assert.Equal(1, changed);
}
[Fact]
public void OnSkillUpdate_StoresFormulaAdjustedCurrent()
{