acdream/docs/research/2026-07-13-retail-experience-update-pseudocode.md
Erik 84b7d2d7cd 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>
2026-07-13 20:43:48 +02:00

81 lines
3.4 KiB
Markdown

# 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.