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 **Severity:** HIGH
**Filed:** 2026-07-13 **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 **Description:** The Attributes window always displayed Total Experience as 0
the ground-drop path. A starter-dungeon character therefore could not hand the and an empty level-progress meter after the player earned XP. The Skills footer
exit token to the exit NPC and could not complete the dungeon. 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. **Root cause / status:** acdream parsed the neighboring private/public Int32
The exact `AttemptPlaceIn3D` policy and the `0x00CD` quality messages (`0x02CD`/`0x02CE`) but omitted retail
packet builder already existed, but `GameWindow.OnUiDragReleasedOutside` `PrivateUpdatePropertyInt64 (0x02CF)`. Both TotalExperience (quality 1) and
discarded the release coordinates and always called `DropToWorld`, hard-coding AvailableExperience (quality 2) use that message. The exact parser/event/state
`TargetId=0`. The release path now world-picks at the actual release point, path is now ported, both local-player projections are synchronized in one
the controller executes its existing `GiveToTarget` action without optimistic wiring owner, the existing retail XP-band formula receives live data, and only
mutation, `WorldSession` sends the exact request, and PlayerDescription drives the value element `0x10000235` is explicitly right-aligned.
the retail player secure-trade preference.
**Files:** `src/AcDream.App/UI/ItemInteractionController.cs`; **Files:** `src/AcDream.Core.Net/Messages/PrivateUpdatePropertyInt64.cs`;
`src/AcDream.App/Rendering/GameWindow.cs`; `src/AcDream.Core.Net/WorldSession.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`; **Research:** `docs/research/2026-07-13-retail-experience-update-pseudocode.md`;
`ItemHolder::AttemptPlaceIn3D @ 0x00588600`; `CM_Qualities::DispatchUI_PrivateUpdateInt64 @ 0x006AEAD0`;
`ACCWeenieObject::UIAttemptGive @ 0x0058D620`; `ClientObjMaintSystem::Handle_Qualities__PrivateUpdateInt64 @ 0x00559000`;
`CM_Inventory::Event_GiveObjectRequest @ 0x006ABB00`. `gmStatManagementUI::UpdateExperience @ 0x004F0A70`.
**Acceptance:** Drag the starter-dungeon exit token from the backpack onto the **Acceptance:** Earn XP while logged in. Attributes immediately shows the new
exit NPC. ACE accepts the token, the inventory removes/decrements it only from right-aligned total and red progress fill; Skills immediately shows the same
the authoritative server response, and the quest teleport exits the dungeon. 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). **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. 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: 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. - **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`. - **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. - **✓ 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. - **✓ 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. - **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. - **✓ 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 + // D.5.4: ingest CreateObject into the object table (upsert) and wire Delete +
// UiEffects live update. Wire BEFORE EntitySpawned += OnLiveEntitySpawned so // UiEffects live update. Wire BEFORE EntitySpawned += OnLiveEntitySpawned so
// the table is populated before the render handler runs. // 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); AcDream.Core.Net.CombatStateWiring.Wire(session, Combat);
_liveSession.EntitySpawned += OnLiveEntitySpawned; _liveSession.EntitySpawned += OnLiveEntitySpawned;
_liveSession.EntityDeleted += OnLiveEntityDeleted; _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. // TotalXpLabel (16px from dat) + TotalXp (16px from dat): pass null → keep dat font.
LabelLeft(layout, contentPage, TotalXpLabelId, null, Body, static () => "Total Experience (XP):"); 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). // XP-to-level meter fill (gmStatManagementUI::UpdateExperience 0x004f0a70).
// Fix 5: child elements 0x10000237 (label) and 0x10000238 (value) are now built by // 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. /// <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>. /// 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 /// 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.Items;
using AcDream.Core.Player;
namespace AcDream.Core.Net; namespace AcDream.Core.Net;
@ -6,8 +7,9 @@ namespace AcDream.Core.Net;
/// Wires WorldSession GameMessage-level object events into the client object /// Wires WorldSession GameMessage-level object events into the client object
/// table: CreateObject (0xF745) = canonical merge-upsert, DeleteObject (0xF747) /// table: CreateObject (0xF745) = canonical merge-upsert, DeleteObject (0xF747)
/// = evict, PublicUpdatePropertyInt (0x02CE) = live int apply, PrivateUpdatePropertyInt /// = evict, PublicUpdatePropertyInt (0x02CE) = live int apply, PrivateUpdatePropertyInt
/// (0x02CD) = player int (burden), SetStackSize (0x0197) = stack count, InventoryRemoveObject /// (0x02CD) = player int (burden), PrivateUpdatePropertyInt64 (0x02CF) = player
/// (0x0024) = inventory-view removal. /// 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. /// Keeps object ingestion in Core.Net (pure data, no GL) and off GameWindow.
/// Retail: ACCObjectMaint::CreateObject / DeleteObject (the weenie_object_table side). /// Retail: ACCObjectMaint::CreateObject / DeleteObject (the weenie_object_table side).
/// </summary> /// </summary>
@ -18,7 +20,11 @@ public static class ObjectTableWiring
/// on <paramref name="session"/>. Call this BEFORE the render handler subscribes /// on <paramref name="session"/>. Call this BEFORE the render handler subscribes
/// to EntitySpawned so the table is populated before the render path runs. /// to EntitySpawned so the table is populated before the render path runs.
/// </summary> /// </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(session);
ArgumentNullException.ThrowIfNull(table); ArgumentNullException.ThrowIfNull(table);
@ -50,6 +56,14 @@ public static class ObjectTableWiring
table.UpdateIntProperty(playerGuid(), u.Property, u.Value); 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. // B-Wire: SetStackSize (0x0197) — update the object's stack count + value.
session.StackSizeUpdated += u => table.UpdateStackSize(u.Guid, u.StackSize, u.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); 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> /// <summary>Translate the wire spawn into the table's merge patch.</summary>
public static WeenieData ToWeenieData(WorldSession.EntitySpawn s) => new( public static WeenieData ToWeenieData(WorldSession.EntitySpawn s) => new(
Guid: s.Guid, 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> /// PropertyInt updated on the player. B-Wire routes EncumbranceVal (5) to the burden bar.</summary>
public event Action<PlayerIntPropertyUpdate>? PlayerIntPropertyUpdated; 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 /// <summary>Payload for <see cref="StackSizeUpdated"/>: SetStackSize (0x0197) — a stack's
/// count + value after a merge / split.</summary> /// count + value after a merge / split.</summary>
public readonly record struct StackSizeUpdate(uint Guid, int StackSize, int Value); public readonly record struct StackSizeUpdate(uint Guid, int StackSize, int Value);
@ -1078,6 +1087,15 @@ public sealed class WorldSession : IDisposable
PlayerIntPropertyUpdated?.Invoke( PlayerIntPropertyUpdated?.Invoke(
new PlayerIntPropertyUpdate(p.Value.Property, p.Value.Value)); 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) else if (op == SetStackSize.Opcode)
{ {
var p = SetStackSize.TryParse(body); var p = SetStackSize.TryParse(body);

View file

@ -327,6 +327,18 @@ public sealed class LocalPlayerState
CharacterChanged?.Invoke(); 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> /// <summary>Apply or replace one PlayerDescription skill entry.</summary>
public void OnSkillUpdate( public void OnSkillUpdate(
uint skillId, uint skillId,

View file

@ -99,6 +99,23 @@ public sealed class CharacterSheetProviderTests
Assert.Equal(90L, sheet.AttributeRaise10Costs[0]); 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] [Fact]
public void BuildSheet_Skills_MapsAdvancementAndCurveCosts() 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)"); 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 ───────────── // ── Polish Commit 1: name white, Infinity!, white footer title ─────────────
[Fact] [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.Items;
using AcDream.Core.Net; using AcDream.Core.Net;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Player;
namespace AcDream.Core.Net.Tests; namespace AcDream.Core.Net.Tests;
@ -116,6 +117,37 @@ public sealed class ObjectTableWiringTests
Assert.Equal(0x50000001u, d.PetOwnerId); 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. // The handler that ObjectTableWiring.Wire subscribes to session.EntityDeleted.
// Testing it directly avoids needing a live WorldSession (UDP socket). // 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); 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] [Fact]
public void TryParse_AttributeBlock_PopulatesAllNineEntries() 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)); 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] [Fact]
public void OnSkillUpdate_StoresFormulaAdjustedCurrent() public void OnSkillUpdate_StoresFormulaAdjustedCurrent()
{ {