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
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -504,6 +504,7 @@ behavior. Estimated 17–26 days focused work, 3–5 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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue