feat(items): port retail shared cooldown overlays

Preserve public shared-cooldown metadata, resolve the authoritative cooldown enchantment with retail expiry semantics, and project the exact ten DAT-authored radial steps through the shared retained item-slot architecture.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-23 10:41:16 +02:00
parent 19e8863f4e
commit 6b1ae4fb76
27 changed files with 875 additions and 20 deletions

View file

@ -107,7 +107,7 @@ accepted-divergence entries (#96, #49, #50).
--- ---
## 3. Documented approximation (AP) — 89 active rows ## 3. Documented approximation (AP) — 90 active rows
Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84
collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered
@ -219,6 +219,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-120 | `ObjectTableWiring.ApplyEntitySpawn` publishes the CreateObject's WeenieDesc/item state before the same-generation physics update tail. Retail applies WeenieDesc after ObjDesc, Parent-or-Position/Pickup, Movement, State, and Vector. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (`ApplyEntitySpawn`); `src/AcDream.App/World/LiveEntitySameGenerationUpdateRouter.cs` | Core.Net owns item-table ingestion before App callbacks and the current single-thread FIFO prevents a second network packet from interleaving; changing publication order crosses the Core.Net/App ownership boundary and requires a separately tested event transaction. | A synchronous item-table observer can see the refreshed WeenieDesc while the same object's physics/parent/state still reflects the prior snapshot; retail observers see the completed physics tail first. | `SmartBox::HandleCreateObject @ 0x00454C80`; `ACCObjectMaint::CreateObject @ 0x00558870` | | AP-120 | `ObjectTableWiring.ApplyEntitySpawn` publishes the CreateObject's WeenieDesc/item state before the same-generation physics update tail. Retail applies WeenieDesc after ObjDesc, Parent-or-Position/Pickup, Movement, State, and Vector. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (`ApplyEntitySpawn`); `src/AcDream.App/World/LiveEntitySameGenerationUpdateRouter.cs` | Core.Net owns item-table ingestion before App callbacks and the current single-thread FIFO prevents a second network packet from interleaving; changing publication order crosses the Core.Net/App ownership boundary and requires a separately tested event transaction. | A synchronous item-table observer can see the refreshed WeenieDesc while the same object's physics/parent/state still reflects the prior snapshot; retail observers see the completed physics tail first. | `SmartBox::HandleCreateObject @ 0x00454C80`; `ACCObjectMaint::CreateObject @ 0x00558870` |
| AP-121 | The `/framerate` command persists acdream's live ShowFps value in the modern `settings.json` Display bag after performing retail's live toggle/notice behavior. The named retail `DoFrameRate` body itself only flips `fShowFramerate` and sends `SetFramerateDisplay`; no equivalent persistence write occurs in that command body. | `src/AcDream.App/Settings/RuntimeSettingsController.cs` (`ToggleFrameRate`) | This preserves acdream's shipped L.0 cross-launch display preference while keeping the live retail mechanism and SmartBox visibility result exact; it changes no gameplay or wire state | A relaunch can retain FPS visibility where the named retail command body alone would not; a future port of retail's wider preference owner must avoid double-persisting or applying conflicting startup state | `ClientCommunicationSystem::DoFrameRate @ 0x005707D0`; `CM_UI::SendNotice_SetFramerateDisplay @ 0x0047A050`; `gmSmartBoxUI::RecvNotice_SetFramerateDisplay @ 0x004D65E0` | | AP-121 | The `/framerate` command persists acdream's live ShowFps value in the modern `settings.json` Display bag after performing retail's live toggle/notice behavior. The named retail `DoFrameRate` body itself only flips `fShowFramerate` and sends `SetFramerateDisplay`; no equivalent persistence write occurs in that command body. | `src/AcDream.App/Settings/RuntimeSettingsController.cs` (`ToggleFrameRate`) | This preserves acdream's shipped L.0 cross-launch display preference while keeping the live retail mechanism and SmartBox visibility result exact; it changes no gameplay or wire state | A relaunch can retain FPS visibility where the named retail command body alone would not; a future port of retail's wider preference owner must avoid double-persisting or applying conflicting startup state | `ClientCommunicationSystem::DoFrameRate @ 0x005707D0`; `CM_UI::SendNotice_SetFramerateDisplay @ 0x0047A050`; `gmSmartBoxUI::RecvNotice_SetFramerateDisplay @ 0x004D65E0` |
| AP-122 | The toolbar Use hand is Ghosted when selection is empty. Retail sets state 1 (Normal) and an empty-selection click enters generic `TARGET_MODE_USE`; selected-object enablement and activation remain retail-faithful. | `src/AcDream.App/UI/Layout/ToolbarController.cs` (`RefreshUseButton`) | User explicitly requires no selection to present as unavailable. The disabled retained button consumes the click without opening a cursor; every nonempty selection still uses the exact retail CombatUse/type/ItemUses predicate and the shared activation owner. | Generic pick-then-use mode cannot be entered from the empty toolbar hand; an object must first be selected, after which the same Use command is available. | `gmToolbarUI::HandleSelectionChanged @ 0x004BF380`; `gmToolbarUI::ListenToElementMessage @ 0x004BEE90` | | AP-122 | The toolbar Use hand is Ghosted when selection is empty. Retail sets state 1 (Normal) and an empty-selection click enters generic `TARGET_MODE_USE`; selected-object enablement and activation remain retail-faithful. | `src/AcDream.App/UI/Layout/ToolbarController.cs` (`RefreshUseButton`) | User explicitly requires no selection to present as unavailable. The disabled retained button consumes the click without opening a cursor; every nonempty selection still uses the exact retail CombatUse/type/ItemUses predicate and the shared activation owner. | Generic pick-then-use mode cannot be entered from the empty toolbar hand; an object must first be selected, after which the same Use command is available. | `gmToolbarUI::HandleSelectionChanged @ 0x004BF380`; `gmToolbarUI::ListenToElementMessage @ 0x004BEE90` |
| AP-123 | Item cooldowns use the retained toolkit's existing procedural `UiItemSlot` leaf rather than materializing retail's ten `m_elem_Icon_Cooldown_*` child elements. The group lookup, remaining-time formula, exact DAT sprites, step choice, and topmost ReadOrder-8 outcome are faithful. | `src/AcDream.App/UI/Layout/ItemCooldownUiController.cs`; `src/AcDream.App/UI/UiItemSlot.cs` | `UiItemSlot` already consumes/reproduces UIItem children procedurally under the IA-15 retained-toolkit architecture. Selecting one imported sprite at draw time gives every inventory/equipment/shortcut alias the same output without a parallel widget tree or per-cell timers. | A future feature that observes the individual cooldown child visibility/state rather than the rendered UIItem could see no child elements even though the cell looks and advances correctly. | `CEnchantmentRegistry::OnCooldown @ 0x005943C0`; `UIElement_UIItem::UpdateCooldownDisplay @ 0x004E1E20`; common UIItem prototype `0x1000033E` |
## 4. Temporary stopgap (TS) — 38 active rows + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) ## 4. Temporary stopgap (TS) — 38 active rows + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains)

View file

@ -25,8 +25,12 @@ through the world-object approach query and silently cancelled because
inventory objects have no spatial entity. Owned direct-use now sends inventory objects have no spatial entity. Owned direct-use now sends
immediately, while the registered AD-27 approach seam remains world-only; immediately, while the registered AD-27 approach seam remains world-only;
shared AutoWear reports the exact overlapping worn item that must be removed. shared AutoWear reports the exact overlapping worn item that must be removed.
That correction awaits its connected gate; the magnifying-glass Assess command That correction is user-confirmed. The requested adjacent cooldown follow-up
follows in the same slice. ports the optional public shared-cooldown metadata, the authoritative
cooldown-enchantment lookup, the exact ten-step bucket formula, and DAT
sprites `0x060067CF..0x060067D8` through the shared retained `UiItemSlot`
presentation. Its visual gate comes next; the magnifying-glass Assess command
then follows in the same slice.
--- ---

View file

@ -92,13 +92,12 @@ broaden the feature surface.
Slice 1's 18-cell favorite-spell overflow bar is user-accepted. Slice 2 is Slice 1's 18-cell favorite-spell overflow bar is user-accepted. Slice 2 is
active: the status hand's selected-object availability and existing active: the status hand's selected-object availability and existing
Use/AutoWield/use-on-target command routing passed their connected gate. Use/AutoWield/use-on-target command routing passed their connected gate.
The follow-up retail correction for zero-valued direct-use items and named The follow-up retail correction for zero-valued direct-use items, named
AutoWear blockers is code-complete. Its first connected gate exposed and AutoWear blockers, and carried-use delivery is user-confirmed. The requested
corrected the lower delivery boundary: carried direct-use had been sent through adjacent cooldown follow-up now preserves the item's shared group/duration,
the world approach query even though inventory objects have no spatial entity. projects the authoritative cooldown-enchantment bucket, and draws retail's ten
Owned Use now sends immediately; world Use alone retains the registered AD-27 DAT-authored radial steps across all item-slot surfaces. Its visual gate is
approach seam. The combined correction awaits its connected gate before the next; the Assess half then continues.
Assess half continues.
Carried: Carried:
#153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus

View file

@ -1,8 +1,9 @@
# World interaction completion — pre-M4 program # World interaction completion — pre-M4 program
**Status:** Slice 1 user-accepted 2026-07-23. Slice 2 Use-hand selection state **Status:** Slice 1 user-accepted 2026-07-23. Slice 2 Use-hand selection,
passed its connected gate. The follow-up zero-useability/AutoWear correction is zero-useability, carried direct-use, and AutoWear correction passed their
code-complete and awaits its connected gate before Assess continues. connected gates. The requested shared item-cooldown follow-up is code-complete
and awaits its visual gate before Assess continues.
**Milestone:** M4 prerequisite/preamble. **Milestone:** M4 prerequisite/preamble.
**Architecture:** retained gameplay UI over shared selection, object, and **Architecture:** retained gameplay UI over shared selection, object, and
interaction state. `GameWindow` remains a composition/callback shell. interaction state. `GameWindow` remains a composition/callback shell.
@ -89,6 +90,22 @@ tests / 5 skips. The carried-use delivery correction adds an end-to-end App
pin from Favor activation through wire dispatch and authoritative UseDone busy pin from Favor activation through wire dispatch and authoritative UseDone busy
release. The Release build retains the 17 tracked test-project warnings; release. The Release build retains the 17 tracked test-project warnings;
3,477 App tests / 3 skips and 7,858 complete-solution tests / 5 skips pass. 3,477 App tests / 3 skips and 7,858 complete-solution tests / 5 skips pass.
The user confirmed Blackmoor's Favor now activates correctly.
Before Assess, the user requested the adjacent retail item-cooldown
presentation. `PublicWeenieDesc` now preserves the optional shared cooldown id
and duration, assessed property updates reach the same object fields, and
`CEnchantmentRegistry::OnCooldown @ 0x005943C0` plus
`UIElement_UIItem::UpdateCooldownDisplay @ 0x004E1E20` are ported through the
canonical Core registry and one pure display projection. Every retained item
list shares one controller and the exact
ten DAT-authored radial sprites `0x060067CF..0x060067D8`; items with the same
group display the same server-authored cooldown. Research:
[`../research/2026-07-23-retail-item-cooldown-pseudocode.md`](../research/2026-07-23-retail-item-cooldown-pseudocode.md).
The focused parser/Core/UI/production-DAT tests, warning-free Release solution
build, 3,482 App tests / 3 skips, and 7,875 complete-solution tests / 5 skips
pass. AP-123 records only the retained toolkit's procedural-child adaptation;
the visible assets, ordering, timing, and shared-group behavior are exact.
## Slice 1 — spell-bar overflow arrows ## Slice 1 — spell-bar overflow arrows
@ -168,9 +185,9 @@ Named retail references and executable pseudocode are recorded in
### Remaining Slice 2 work ### Remaining Slice 2 work
- Run the connected visual gate for the shared item cooldown: use Blackmoor's
Favor or a summon/stone and verify its inventory and shortcut aliases count
down together, then clear.
- Port the magnifying-glass Assess command state and activation. - Port the magnifying-glass Assess command state and activation.
- Run the connected correction gate: activate Blackmoor's Favor, then try to
equip armor blocked by an overlapping worn item and verify the named system
message.
- Continue directly into the Assess information slice after the combined - Continue directly into the Assess information slice after the combined
status-control gate. status-control gate.

View file

@ -128,7 +128,7 @@ gives the per-state sprite ids (column 3 below).
| `m_elem_Icon_OpenContainer` | `0x10000450` | 3 | "this container is open" frame | `DirectState 0x06005D9C Alphablend` (dump:2232) | | `m_elem_Icon_OpenContainer` | `0x10000450` | 3 | "this container is open" frame | `DirectState 0x06005D9C Alphablend` (dump:2232) |
| `m_elem_Icon_DragAccept` | `0x1000045A` | 3 | drag-rollover accept/reject frame | `ItemSlot_DragOver_Accept → 0x060011F9`, `_Reject → 0x060011F8`, `_DropIn → 0x060011F7` (dump:1174-1175,1258-1260) | | `m_elem_Icon_DragAccept` | `0x1000045A` | 3 | drag-rollover accept/reject frame | `ItemSlot_DragOver_Accept → 0x060011F9`, `_Reject → 0x060011F8`, `_DropIn → 0x060011F7` (dump:1174-1175,1258-1260) |
| `m_elem_Icon_Quantity` | `0x100004F5` | 12 (Text) | the stack-count number | — | | `m_elem_Icon_Quantity` | `0x100004F5` | 12 (Text) | the stack-count number | — |
| `m_elem_Icon_Cooldown_10..100` | `0x1000054F..0x10000558` | 3 | 10-step radial cooldown ring | `DirectState 0x0600109D / 0x060012D9 / 0x06001DAE / 0x060067CF..D1 …` (dump:778-863) | | `m_elem_Icon_Cooldown_10..100` | `0x1000054F..0x10000558` | 3 | 10-step radial cooldown ring | Common physical UIItem prototype `0x1000033E`: exact ordered `0x060067CF..0x060067D8`; ReadOrder 8, above every other cell layer. See `2026-07-23-retail-item-cooldown-pseudocode.md`. |
| `m_dragIcon` | `0x10000345` (created) | — | translucent drag-ghost | created via `CreateChildElement(this, dbobj, 0x10000345)`, `SetVisible(0)` (229738-229740) | | `m_dragIcon` | `0x10000345` (created) | — | translucent drag-ghost | created via `CreateChildElement(this, dbobj, 0x10000345)`, `SetVisible(0)` (229738-229740) |
**The four named LayoutDesc states** that drive `m_elem_Icon` / `m_elem_Icon_DragAccept` **The four named LayoutDesc states** that drive `m_elem_Icon` / `m_elem_Icon_DragAccept`

View file

@ -0,0 +1,174 @@
# Retail item cooldown presentation
**Date:** 2026-07-23
**Scope:** the shared radial cooldown overlay drawn on inventory, equipment,
container, and shortcut `UIElement_UIItem` cells.
## Retail oracle
Named-retail symbols:
- `PublicWeenieDesc::UnPack` — public object metadata, including the optional
shared cooldown id and duration.
- `CEnchantmentRegistry::OnCooldown @ 0x005943C0` — resolves the live
cooldown enchantment and its remaining duration.
- `UIElement_UIItem::PostInit @ 0x004E1870` — binds the ten authored cooldown
children.
- `UIElement_UIItem::UpdateCooldownDisplay @ 0x004E1E20` — selects the
visible radial step.
- `UIElement_UIItem::DoHeartbeat @ 0x004E2070` — refreshes the display.
The named pseudo-C loses the x87 expression in
`UpdateCooldownDisplay`. Disassembly of the matching September 2013 client
recovers the exact bucket expression:
```text
truncate((remaining / itemCooldownDuration) * 10 + 1)
```
The result `1..9` selects the 10%..90% child. A result at or above 10 selects
the 100% child. No child is visible when the cooldown is inactive or the item
duration is non-positive.
## Wire metadata
`PublicWeenieDesc` second-header flags:
```text
PWD2_Packed_CooldownID = 0x00000002
PWD2_Packed_CooldownDuration = 0x00000004
```
They occur after IconUnderlay and MaterialType, and before PetOwner:
```text
if secondHeader & 0x00000002:
cooldownId = read_u32()
if secondHeader & 0x00000004:
cooldownDuration = read_f64() // seconds
```
Absence remains distinct from a present zero. Assessment properties use the
same semantic values: SharedCooldown `PropertyInt 280` and CooldownDuration
`PropertyFloat 167`.
## Registry lookup
Retail stores cooldowns in the enchantment-registry cooldown list. The
requested enchantment id is the item's shared cooldown group plus `0x8000`.
The list is examined in stored order and the first matching record decides
the result:
```text
OnCooldown(itemCooldownId, now):
if itemCooldownId == 0:
return inactive
requestedSpellId = itemCooldownId + 0x8000
for record in cooldownList, head to tail:
if low16(record.spellId) != requestedSpellId:
continue
remaining = record.duration + record.startTime - now
if remaining > 0:
return active, remaining
removeExpiredRecord(record)
return inactive
return inactive
```
The server remains authoritative for adding, updating, and removing the
cooldown enchantment. Items that share a cooldown id therefore display the
same live lockout without per-item client timers.
## UI projection
```text
UpdateCooldownDisplay(item, cooldownRegistry, now):
hide cooldown children 10..100
if item.cooldownDuration <= 0:
return
active, remaining =
cooldownRegistry.OnCooldown(item.cooldownId + 0x8000, now)
if not active:
return
step = truncate((remaining / item.cooldownDuration) * 10 + 1)
if step >= 10:
show cooldown_100
else if step >= 1:
show cooldown_[step * 10]
```
`UIElement_UIItem::DoHeartbeat` repeats this projection, so the artwork
advances while no object packet changes.
## DAT-authored presentation
The common physical UIItem prototype is element `0x1000033E` in catalog
LayoutDesc `0x21000037`. Physical-item, container, and shortcut prototypes
inherit the same children:
| Step | Element | RenderSurface |
|---:|---:|---:|
| 10% | `0x1000054F` | `0x060067CF` |
| 20% | `0x10000550` | `0x060067D0` |
| 30% | `0x10000551` | `0x060067D1` |
| 40% | `0x10000552` | `0x060067D2` |
| 50% | `0x10000553` | `0x060067D3` |
| 60% | `0x10000554` | `0x060067D4` |
| 70% | `0x10000555` | `0x060067D5` |
| 80% | `0x10000556` | `0x060067D6` |
| 90% | `0x10000557` | `0x060067D7` |
| 100% | `0x10000558` | `0x060067D8` |
The empty-slot-only prototype `0x10000341` has no cooldown children.
Cooldown children use ReadOrder 8, above icon/quantity/capacity (1),
waiting/drag-accept (3), selection (4), shortcut digits (5), sell (6), and
trade (7). The active cooldown sprite is therefore the last UIItem layer
drawn.
## Reference cross-checks
- ACE serializes `SharedCooldown` (`PropertyInt 280`) and
`CooldownDuration` (`PropertyFloat 167`) in the same optional public
description order. Cooldown enchantments are placed in the registry's
cooldown bucket.
- Holtburger parses the same optional `u32` id and `f64` duration, and keeps
enchantment spell/layer identifiers plus `f64` start/duration values.
These references confirm the packet and registry interpretation. The named
retail client and matching-binary disassembly remain authoritative for lookup
order, the `0x8000` offset, bucket selection, and draw ordering.
## acdream architecture
- `CreateObject` preserves optional cooldown metadata through
`EntitySpawn`, `WeenieData`, and `ClientObject`.
- `Spellbook.OnCooldown` owns the ordered group lookup, remaining-time
calculation, and exact expired-node removal.
- `ItemCooldownDisplay` owns the pure ten-step display calculation.
- `ItemCooldownUiController` binds every current and future `UiItemList` cell
and evaluates represented items once in its pre-draw heartbeat.
- `ItemCooldownAssets` imports all ten sprites from DAT. Missing assets fail
visibly in diagnostics; no fabricated fallback ring is used.
- `UiItemSlot` draws the selected sprite last, so inventory, paperdoll,
external-container, and shortcut aliases share one mechanism.
## Conformance cases
- optional metadata present, absent, and present-zero;
- exact `0x8000` shared-group mapping;
- cooldown-bucket filtering and stored-order first-match behavior;
- expired, zero-duration, and active records;
- exact bucket boundaries and 100% clamping;
- assessed property updates;
- propagation to existing and future item-list cells;
- exact production-DAT sprite sequence.

View file

@ -0,0 +1,56 @@
using AcDream.Content;
using DatReaderWriter;
namespace AcDream.App.UI.Layout;
/// <summary>
/// DAT-authored ten-step radial cooldown art owned by the shared retail
/// <c>UIElement_UIItem</c> prototype.
/// </summary>
public readonly record struct ItemCooldownAssets(IReadOnlyList<uint> Sprites)
{
public const uint CatalogLayoutId = 0x21000037u;
public const uint SharedItemPrototypeId = 0x1000033Eu;
public const uint FirstOverlayElementId = 0x1000054Fu;
public const int OverlayCount = 10;
public static ItemCooldownAssets? TryLoad(IDatReaderWriter dats)
{
ArgumentNullException.ThrowIfNull(dats);
// 0x1000033E is the common UIItem base inherited by physical item,
// container, and shortcut prototypes. The empty-slot prototype
// 0x10000341 is only background art and intentionally has no cooldown
// children.
ElementInfo? prototype = LayoutImporter.ImportInfos(
dats,
CatalogLayoutId,
SharedItemPrototypeId);
if (prototype is null)
return null;
var sprites = new uint[OverlayCount];
for (int index = 0; index < sprites.Length; index++)
{
ElementInfo? overlay = Find(
prototype,
FirstOverlayElementId + (uint)index);
if (overlay is null
|| !overlay.StateMedia.TryGetValue("", out var media)
|| media.File == 0u)
return null;
sprites[index] = media.File;
}
return new ItemCooldownAssets(sprites);
}
private static ElementInfo? Find(ElementInfo root, uint id)
{
if (root.Id == id)
return root;
foreach (ElementInfo child in root.Children)
if (Find(child, id) is { } match)
return match;
return null;
}
}

View file

@ -0,0 +1,101 @@
using AcDream.Core.Items;
using AcDream.Core.Spells;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Shared retained-UI owner for retail item cooldowns. It performs the retail
/// heartbeat before drawing and gives every current and future
/// <see cref="UiItemSlot"/> the same display projection.
/// </summary>
public sealed class ItemCooldownUiController
{
private readonly Spellbook _spellbook;
private readonly ClientObjectTable _objects;
private readonly Func<double> _currentTime;
private readonly List<UiItemList> _lists = new();
private readonly Dictionary<uint, int> _stepByItemId = new();
private ItemCooldownUiController(
Spellbook spellbook,
ClientObjectTable objects,
Func<double> currentTime)
{
_spellbook = spellbook;
_objects = objects;
_currentTime = currentTime;
}
public static ItemCooldownUiController Bind(
UiElement root,
Spellbook spellbook,
ClientObjectTable objects,
Func<double> currentTime,
ItemCooldownAssets assets)
{
ArgumentNullException.ThrowIfNull(root);
ArgumentNullException.ThrowIfNull(spellbook);
ArgumentNullException.ThrowIfNull(objects);
ArgumentNullException.ThrowIfNull(currentTime);
var controller = new ItemCooldownUiController(
spellbook,
objects,
currentTime);
controller.Configure(root, assets.Sprites);
controller.Tick();
return controller;
}
public void Tick()
{
double now = _currentTime();
_stepByItemId.Clear();
// Retail updates cooldown visibility from UIElement_UIItem::DoHeartbeat,
// not while drawing. Query each represented item once so an expired
// registry node can be removed safely before the render walk.
foreach (UiItemList list in _lists)
{
for (int index = 0; index < list.GetNumUIItems(); index++)
{
UiItemSlot? cell = list.GetItem(index);
if (cell is null)
continue;
uint itemId = cell.ItemId;
if (itemId == 0u || _stepByItemId.ContainsKey(itemId))
continue;
int step = 0;
ClientObject? item = _objects.Get(itemId);
if (item?.CooldownId is { } cooldownId
&& item.CooldownDuration is { } duration
&& _spellbook.OnCooldown(
cooldownId,
now,
out double remaining))
step = ItemCooldownDisplay.GetOverlayStep(
duration,
remaining);
_stepByItemId.Add(itemId, step);
}
}
}
internal int GetOverlayStep(uint itemId)
=> _stepByItemId.TryGetValue(itemId, out int step) ? step : 0;
private void Configure(UiElement element, IReadOnlyList<uint> sprites)
{
if (element is UiItemList list)
{
_lists.Add(list);
list.CooldownSprites = sprites;
list.CooldownStepProvider = GetOverlayStep;
}
foreach (UiElement child in element.Children)
Configure(child, sprites);
}
}

View file

@ -198,6 +198,7 @@ public sealed class RetailUiRuntime : IDisposable
private RetailItemConfirmationController? _itemConfirmationController; private RetailItemConfirmationController? _itemConfirmationController;
private RetailSkillTrainingConfirmationController? _skillTrainingConfirmationController; private RetailSkillTrainingConfirmationController? _skillTrainingConfirmationController;
private UiShortcutDigitGraphics? _shortcutDigitGraphics; private UiShortcutDigitGraphics? _shortcutDigitGraphics;
private ItemCooldownUiController? _itemCooldownController;
private VividTargetIndicatorController? _vividTargetIndicator; private VividTargetIndicatorController? _vividTargetIndicator;
private ResourceShutdownTransaction? _shutdown; private ResourceShutdownTransaction? _shutdown;
private bool _disposed; private bool _disposed;
@ -242,6 +243,7 @@ public sealed class RetailUiRuntime : IDisposable
MountPlugins(); MountPlugins();
MountInventory(); MountInventory();
MountExternalContainer(); MountExternalContainer();
MountItemCooldowns();
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged; Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
BindToolbarPanelButtons(); BindToolbarPanelButtons();
SyncToolbarWindowButtons(); SyncToolbarWindowButtons();
@ -346,6 +348,7 @@ public sealed class RetailUiRuntime : IDisposable
JumpPowerbarController?.Tick(); JumpPowerbarController?.Tick();
SelectedObjectController?.Tick(deltaSeconds); SelectedObjectController?.Tick(deltaSeconds);
ExternalContainerController?.Tick(); ExternalContainerController?.Tick();
_itemCooldownController?.Tick();
DialogFactory?.Tick(); DialogFactory?.Tick();
Host.Tick(deltaSeconds); Host.Tick(deltaSeconds);
_automation?.Tick(deltaSeconds); _automation?.Tick(deltaSeconds);
@ -1760,6 +1763,29 @@ public sealed class RetailUiRuntime : IDisposable
"[D.2b] retail external-container strip mounted from LayoutDesc 0x21000008."); "[D.2b] retail external-container strip mounted from LayoutDesc 0x21000008.");
} }
private void MountItemCooldowns()
{
ItemCooldownAssets? assets;
lock (_bindings.Assets.DatLock)
assets = ItemCooldownAssets.TryLoad(_bindings.Assets.Dats);
if (assets is null)
{
Console.WriteLine(
"[UI] shared UIItem cooldown overlays missing from LayoutDesc 0x21000037.");
return;
}
_itemCooldownController = ItemCooldownUiController.Bind(
Host.Root,
_bindings.Magic.Spellbook,
_bindings.Magic.Objects,
_bindings.Magic.ServerTime,
assets.Value);
Console.WriteLine(
"[UI] retail shared item cooldown overlays ready (10 DAT-authored steps).");
}
public void Dispose() public void Dispose()
{ {
if (_disposed) if (_disposed)

View file

@ -39,6 +39,30 @@ public sealed class UiItemList : UiElement
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; } public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
private IReadOnlyList<uint>? _cooldownSprites;
public IReadOnlyList<uint>? CooldownSprites
{
get => _cooldownSprites;
set
{
_cooldownSprites = value;
foreach (UiItemSlot cell in _cells)
cell.CooldownSprites = value;
}
}
private Func<uint, int>? _cooldownStepProvider;
public Func<uint, int>? CooldownStepProvider
{
get => _cooldownStepProvider;
set
{
_cooldownStepProvider = value;
foreach (UiItemSlot cell in _cells)
cell.CooldownStepProvider = value;
}
}
/// <summary> /// <summary>
/// Optional non-weenie catalog drop target. Used by retail spell shortcuts; /// Optional non-weenie catalog drop target. Used by retail spell shortcuts;
/// physical-item drops continue through <see cref="DragHandler"/>. /// physical-item drops continue through <see cref="DragHandler"/>.
@ -92,6 +116,8 @@ public sealed class UiItemList : UiElement
public void AddItem(UiItemSlot cell) public void AddItem(UiItemSlot cell)
{ {
cell.SpriteResolve ??= SpriteResolve; cell.SpriteResolve ??= SpriteResolve;
cell.CooldownSprites ??= _cooldownSprites;
cell.CooldownStepProvider ??= _cooldownStepProvider;
if (_cellEmptySprite != 0) cell.EmptySprite = _cellEmptySprite; if (_cellEmptySprite != 0) cell.EmptySprite = _cellEmptySprite;
// The list lays cells out procedurally (grid offset + scroll clip), so cells must be // The list lays cells out procedurally (grid offset + scroll clip), so cells must be
// EXEMPT from the parent anchor pass — UiElement.DrawSelfAndChildren runs ApplyAnchor // EXEMPT from the parent anchor pass — UiElement.DrawSelfAndChildren runs ApplyAnchor
@ -271,6 +297,8 @@ public sealed class UiItemList : UiElement
{ {
UiItemSlot cell = EmptySlotFactory?.Invoke() ?? new UiItemSlot(); UiItemSlot cell = EmptySlotFactory?.Invoke() ?? new UiItemSlot();
cell.SpriteResolve ??= SpriteResolve; cell.SpriteResolve ??= SpriteResolve;
cell.CooldownSprites ??= _cooldownSprites;
cell.CooldownStepProvider ??= _cooldownStepProvider;
if (_cellEmptySprite != 0) cell.EmptySprite = _cellEmptySprite; if (_cellEmptySprite != 0) cell.EmptySprite = _cellEmptySprite;
cell.Anchors = AnchorEdges.None; cell.Anchors = AnchorEdges.None;
_cells.Add(cell); _cells.Add(cell);

View file

@ -103,6 +103,18 @@ public class UiItemSlot : UiElement
/// <summary>RenderSurface id -> (GL texture, w, h). Set by the factory/controller.</summary> /// <summary>RenderSurface id -> (GL texture, w, h). Set by the factory/controller.</summary>
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; } public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
/// <summary>
/// DAT-authored 10%..100% radial cooldown overlays from element ids
/// <c>0x1000054F..0x10000558</c>.
/// </summary>
public IReadOnlyList<uint>? CooldownSprites { get; set; }
/// <summary>
/// Bound by the shared cooldown controller. Returns 0 for hidden or 1..10
/// for the exact retail overlay step.
/// </summary>
public Func<uint, int>? CooldownStepProvider { get; set; }
public void SetItem( public void SetItem(
uint itemId, uint itemId,
uint iconTexture, uint iconTexture,
@ -387,6 +399,28 @@ public class UiItemSlot : UiElement
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
} }
} }
// UIElement_UIItem::UpdateCooldownDisplay @ 0x004E1E20 selects exactly
// one of ten authored radial overlays. The shared prototype gives these
// children ReadOrder 8, above shortcut, waiting, selection, trade, and
// drag-accept layers, so the countdown is the final UIItem draw.
uint cooldownSprite = ActiveCooldownSprite();
if (cooldownSprite != 0u && SpriteResolve is not null)
{
var (texture, _, _) = SpriteResolve(cooldownSprite);
if (texture != 0u)
ctx.DrawSprite(
texture,
0f,
0f,
Width,
Height,
0f,
0f,
1f,
1f,
Vector4.One);
}
} }
/// <summary>Draws the shared retail shortcut-number layer. Catalog spell cells /// <summary>Draws the shared retail shortcut-number layer. Catalog spell cells
@ -409,4 +443,17 @@ public class UiItemSlot : UiElement
if (texture != 0) if (texture != 0)
ctx.DrawSprite(texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); ctx.DrawSprite(texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
} }
internal uint ActiveCooldownSprite()
{
if (ItemId == 0u
|| CooldownSprites is null
|| CooldownStepProvider is null)
return 0u;
int step = CooldownStepProvider(ItemId);
return step is >= 1 and <= 10 && step <= CooldownSprites.Count
? CooldownSprites[step - 1]
: 0u;
}
} }

View file

@ -210,6 +210,11 @@ public static class CreateObject
// PublicWeenieDesc._spellID, gated by PWD_Packed_SpellID. The packed // PublicWeenieDesc._spellID, gated by PWD_Packed_SpellID. The packed
// wire field is u16 and widens into retail's u32 runtime member. // wire field is u16 and widens into retail's u32 runtime member.
uint? SpellId = null, uint? SpellId = null,
// PublicWeenieDesc second-header cooldown metadata. The group id is
// compared against the player's cooldown enchantment list; the
// duration scales the shared UIItem radial countdown.
uint? CooldownId = null,
double? CooldownDuration = null,
// Complete immutable PhysicsDesc projection. Kept alongside the // Complete immutable PhysicsDesc projection. Kept alongside the
// legacy convenience fields while live-entity ownership migrates to // legacy convenience fields while live-entity ownership migrates to
// LiveEntityRuntime in Step 2. // LiveEntityRuntime in Step 2.
@ -871,6 +876,8 @@ public static class CreateObject
uint uiEffects = 0; uint uiEffects = 0;
uint weenieFlags2 = 0; uint weenieFlags2 = 0;
uint? petOwnerId = null; uint? petOwnerId = null;
uint? cooldownId = null;
double? cooldownDuration = null;
try try
{ {
// BF_INCLUDES_SECOND_HEADER = 0x04000000 per acclient.h:6458 // BF_INCLUDES_SECOND_HEADER = 0x04000000 per acclient.h:6458
@ -1075,11 +1082,12 @@ public static class CreateObject
if ((weenieFlags2 & 0x00000002u) != 0) // CooldownId u32 if ((weenieFlags2 & 0x00000002u) != 0) // CooldownId u32
{ {
if (body.Length - pos < 4) throw new FormatException("trunc CooldownId"); if (body.Length - pos < 4) throw new FormatException("trunc CooldownId");
pos += 4; cooldownId = ReadU32(body, ref pos);
} }
if ((weenieFlags2 & 0x00000004u) != 0) // CooldownDuration f64 if ((weenieFlags2 & 0x00000004u) != 0) // CooldownDuration f64
{ {
if (body.Length - pos < 8) throw new FormatException("trunc CooldownDuration"); if (body.Length - pos < 8) throw new FormatException("trunc CooldownDuration");
cooldownDuration = BinaryPrimitives.ReadDoubleLittleEndian(body.Slice(pos));
pos += 8; pos += 8;
} }
if ((weenieFlags2 & 0x00000008u) != 0) // PetOwner u32 if ((weenieFlags2 & 0x00000008u) != 0) // PetOwner u32
@ -1119,6 +1127,8 @@ public static class CreateObject
PetOwnerId: petOwnerId, PetOwnerId: petOwnerId,
AmmoType: ammoType, AmmoType: ammoType,
SpellId: spellId, SpellId: spellId,
CooldownId: cooldownId,
CooldownDuration: cooldownDuration,
Physics: physics); Physics: physics);
} }
catch catch

View file

@ -184,5 +184,7 @@ public static class ObjectTableWiring
PluralName: s.PluralName, PluralName: s.PluralName,
PetOwnerId: s.PetOwnerId, PetOwnerId: s.PetOwnerId,
AmmoType: s.AmmoType, AmmoType: s.AmmoType,
SpellId: s.SpellId); SpellId: s.SpellId,
CooldownId: s.CooldownId,
CooldownDuration: s.CooldownDuration);
} }

View file

@ -154,6 +154,8 @@ public sealed class WorldSession : IDisposable
uint? PetOwnerId = null, uint? PetOwnerId = null,
ushort? AmmoType = null, ushort? AmmoType = null,
uint? SpellId = null, uint? SpellId = null,
uint? CooldownId = null,
double? CooldownDuration = null,
PhysicsSpawnData? Physics = null); PhysicsSpawnData? Physics = null);
/// <summary> /// <summary>
@ -215,6 +217,8 @@ public sealed class WorldSession : IDisposable
PetOwnerId: parsed.PetOwnerId, PetOwnerId: parsed.PetOwnerId,
AmmoType: parsed.AmmoType, AmmoType: parsed.AmmoType,
SpellId: parsed.SpellId, SpellId: parsed.SpellId,
CooldownId: parsed.CooldownId,
CooldownDuration: parsed.CooldownDuration,
Physics: parsed.Physics); Physics: parsed.Physics);
/// <summary>Fires when the session finishes parsing a CreateObject.</summary> /// <summary>Fires when the session finishes parsing a CreateObject.</summary>

View file

@ -193,6 +193,17 @@ public sealed class ClientObject
public ushort? AmmoType { get; set; } public ushort? AmmoType { get; set; }
/// <summary>Retail <c>PublicWeenieDesc._spellID</c>; used by caster endowments.</summary> /// <summary>Retail <c>PublicWeenieDesc._spellID</c>; used by caster endowments.</summary>
public uint? SpellId { get; set; } public uint? SpellId { get; set; }
/// <summary>
/// Retail <c>PublicWeenieDesc._cooldown_id</c>. Positive values name a
/// shared item-cooldown group whose player enchantment id is
/// <c>CooldownId + 0x8000</c>.
/// </summary>
public uint? CooldownId { get; set; }
/// <summary>
/// Retail <c>PublicWeenieDesc._cooldown_duration</c>, in seconds. This is
/// the denominator used by <c>UIElement_UIItem::UpdateCooldownDisplay</c>.
/// </summary>
public double? CooldownDuration { get; set; }
/// <summary>Client-side trade state used by ItemHolder legality gates.</summary> /// <summary>Client-side trade state used by ItemHolder legality gates.</summary>
public int TradeState { get; set; } public int TradeState { get; set; }
/// <summary>Resolved membership in SpellComponentTable (retail IsComponentPack).</summary> /// <summary>Resolved membership in SpellComponentTable (retail IsComponentPack).</summary>
@ -261,7 +272,9 @@ public readonly record struct WeenieData(
string? PluralName = null, string? PluralName = null,
uint? PetOwnerId = null, uint? PetOwnerId = null,
ushort? AmmoType = null, ushort? AmmoType = null,
uint? SpellId = null); uint? SpellId = null,
uint? CooldownId = null,
double? CooldownDuration = null);
/// <summary> /// <summary>
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0). /// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).

View file

@ -194,6 +194,8 @@ public sealed class ClientObjectTable
/// <see cref="ClientObject.Effects"/>.</summary> /// <see cref="ClientObject.Effects"/>.</summary>
public const uint UiEffectsPropertyId = 18u; public const uint UiEffectsPropertyId = 18u;
public const uint CurrentWieldedLocationPropertyId = 10u; public const uint CurrentWieldedLocationPropertyId = 10u;
public const uint SharedCooldownPropertyId = 280u;
public const uint CooldownDurationPropertyId = 167u;
public int ObjectCount => _objects.Count; public int ObjectCount => _objects.Count;
public int ContainerCount => _containers.Count; public int ContainerCount => _containers.Count;
@ -613,6 +615,7 @@ public sealed class ClientObjectTable
foreach (var kv in incoming.Strings) item.Properties.Strings[kv.Key] = kv.Value; foreach (var kv in incoming.Strings) item.Properties.Strings[kv.Key] = kv.Value;
foreach (var kv in incoming.DataIds) item.Properties.DataIds[kv.Key] = kv.Value; foreach (var kv in incoming.DataIds) item.Properties.DataIds[kv.Key] = kv.Value;
foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value; foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value;
ApplyCooldownProperties(item, incoming);
ObjectUpdated?.Invoke(item); ObjectUpdated?.Invoke(item);
return true; return true;
} }
@ -640,6 +643,7 @@ public sealed class ClientObjectTable
foreach (var kv in incoming.Strings) item.Properties.Strings[kv.Key] = kv.Value; foreach (var kv in incoming.Strings) item.Properties.Strings[kv.Key] = kv.Value;
foreach (var kv in incoming.DataIds) item.Properties.DataIds[kv.Key] = kv.Value; foreach (var kv in incoming.DataIds) item.Properties.DataIds[kv.Key] = kv.Value;
foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value; foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value;
ApplyCooldownProperties(item, incoming);
if (!existed) ObjectAdded?.Invoke(item); else ObjectUpdated?.Invoke(item); if (!existed) ObjectAdded?.Invoke(item); else ObjectUpdated?.Invoke(item);
} }
@ -656,6 +660,7 @@ public sealed class ClientObjectTable
ClientObjectPlacement previous = ClientObjectPlacement.From(item); ClientObjectPlacement previous = ClientObjectPlacement.From(item);
item.Properties.Ints[propertyId] = value; item.Properties.Ints[propertyId] = value;
if (propertyId == UiEffectsPropertyId) item.Effects = (uint)value; if (propertyId == UiEffectsPropertyId) item.Effects = (uint)value;
if (propertyId == SharedCooldownPropertyId) item.CooldownId = (uint)value;
if (propertyId == CurrentWieldedLocationPropertyId) if (propertyId == CurrentWieldedLocationPropertyId)
item.CurrentlyEquippedLocation = (EquipMask)(uint)value; item.CurrentlyEquippedLocation = (EquipMask)(uint)value;
if (propertyId == CurrentWieldedLocationPropertyId) if (propertyId == CurrentWieldedLocationPropertyId)
@ -664,6 +669,20 @@ public sealed class ClientObjectTable
return true; return true;
} }
private static void ApplyCooldownProperties(
ClientObject item,
PropertyBundle properties)
{
if (properties.Ints.TryGetValue(
SharedCooldownPropertyId,
out int cooldownId))
item.CooldownId = (uint)cooldownId;
if (properties.Floats.TryGetValue(
CooldownDurationPropertyId,
out double cooldownDuration))
item.CooldownDuration = cooldownDuration;
}
/// <summary> /// <summary>
/// Apply a single PropertyInt64 update to an object's bundle and fire /// Apply a single PropertyInt64 update to an object's bundle and fire
/// ObjectUpdated so bound widgets refresh. Int64 counterpart of /// ObjectUpdated so bound widgets refresh. Int64 counterpart of
@ -738,6 +757,9 @@ public sealed class ClientObjectTable
if (d.CombatUse is { } combatUse) obj.CombatUse = combatUse; if (d.CombatUse is { } combatUse) obj.CombatUse = combatUse;
if (d.AmmoType is { } ammoType) obj.AmmoType = ammoType; if (d.AmmoType is { } ammoType) obj.AmmoType = ammoType;
if (d.SpellId is { } spellId) obj.SpellId = spellId; if (d.SpellId is { } spellId) obj.SpellId = spellId;
if (d.CooldownId is { } cooldownId) obj.CooldownId = cooldownId;
if (d.CooldownDuration is { } cooldownDuration)
obj.CooldownDuration = cooldownDuration;
if (d.RadarBlipColor is { } radarBlipColor) obj.RadarBlipColor = radarBlipColor; if (d.RadarBlipColor is { } radarBlipColor) obj.RadarBlipColor = radarBlipColor;
if (d.RadarBehavior is { } radarBehavior) obj.RadarBehavior = radarBehavior; if (d.RadarBehavior is { } radarBehavior) obj.RadarBehavior = radarBehavior;
if (d.ItemsCapacity is { } ic) obj.ItemsCapacity = ic; if (d.ItemsCapacity is { } ic) obj.ItemsCapacity = ic;

View file

@ -0,0 +1,31 @@
namespace AcDream.Core.Items;
/// <summary>
/// Retail item-cooldown projection shared by inventory, equipment, and shortcut
/// UIItems.
///
/// <para>
/// Port of <c>UIElement_UIItem::UpdateCooldownDisplay @ 0x004E1E20</c>.
/// The remaining fraction selects one of ten authored radial overlays.
/// </para>
/// </summary>
public static class ItemCooldownDisplay
{
/// <summary>
/// Retail's exact ten-step display formula:
/// <c>trunc((remaining / itemDuration) * 10 + 1)</c>. Results 1..9 select
/// the 10%..90% art, while 10 or above selects the 100% art.
/// </summary>
public static int GetOverlayStep(double itemDuration, double remaining)
{
if (!(itemDuration > 0d) || !(remaining > 0d))
return 0;
double step = Math.Truncate((remaining / itemDuration) * 10d + 1d);
if (!(step >= 1d))
return 0;
if (step >= 10d)
return 10;
return (int)step;
}
}

View file

@ -17,6 +17,9 @@ namespace AcDream.Core.Spells;
/// </summary> /// </summary>
public sealed class Spellbook public sealed class Spellbook
{ {
public const uint CooldownSpellOffset = 0x8000u;
public const uint CooldownBucket = 8u;
private readonly Dictionary<uint, float> _learnedSpells = new(); private readonly Dictionary<uint, float> _learnedSpells = new();
private readonly Dictionary<uint, ActiveEnchantmentRecord> _activeById = new(); private readonly Dictionary<uint, ActiveEnchantmentRecord> _activeById = new();
private readonly Dictionary<uint, List<uint>> _enchantmentOrderByBucket = new(); private readonly Dictionary<uint, List<uint>> _enchantmentOrderByBucket = new();
@ -240,6 +243,46 @@ public sealed class Spellbook
if (changed) NotifyEnchantmentsChanged(); if (changed) NotifyEnchantmentsChanged();
} }
/// <summary>
/// Retail <c>CEnchantmentRegistry::OnCooldown @ 0x005943C0</c>.
/// Scans the cooldown list head-to-tail, stops at the first shared-group
/// match, and removes that node when its monotonic lifetime has expired.
/// </summary>
public bool OnCooldown(
uint cooldownId,
double currentTime,
out double remaining)
{
remaining = 0d;
if (cooldownId == 0u
|| !_enchantmentOrderByBucket.TryGetValue(
CooldownBucket,
out List<uint>? order))
return false;
uint cooldownSpellId = unchecked(cooldownId + CooldownSpellOffset);
for (int index = 0; index < order.Count; index++)
{
uint identity = order[index];
if (!_activeById.TryGetValue(
identity,
out ActiveEnchantmentRecord record)
|| (record.SpellId & 0xFFFFu) != cooldownSpellId)
continue;
remaining = record.Duration + record.StartTime - currentTime;
if (remaining > 0d)
return true;
RemoveActiveEnchantment(identity, out _);
EnchantmentRemoved?.Invoke(record);
NotifyEnchantmentsChanged();
return false;
}
return false;
}
/// <summary> /// <summary>
/// Retail EnchantmentRegistry::PurgeEnchantments (0x00594DE0): purge only /// Retail EnchantmentRegistry::PurgeEnchantments (0x00594DE0): purge only
/// finite-duration multiplicative/additive enchantments. Vitae, cooldowns, /// finite-duration multiplicative/additive enchantments. Vitae, cooldowns,

View file

@ -0,0 +1,62 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Spells;
namespace AcDream.App.Tests.UI.Layout;
public sealed class ItemCooldownUiControllerTests
{
[Fact]
public void Shared_group_updates_existing_and_future_UIItems_from_one_clock()
{
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = 0x5001u,
CooldownId = 42u,
CooldownDuration = 30d,
});
var spellbook = new Spellbook();
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 0x802Au,
LayerId: 1u,
Duration: 30d,
CasterGuid: 0u,
Bucket: Spellbook.CooldownBucket,
StartTime: 100d));
double now = 112.5d;
var root = new UiPanel();
var list = new UiItemList();
list.Cell.SetItem(0x5001u, 99u);
root.AddChild(list);
uint[] sprites = Enumerable.Range(1, 10)
.Select(index => 0x06000000u + (uint)index)
.ToArray();
ItemCooldownUiController controller = ItemCooldownUiController.Bind(
root,
spellbook,
objects,
() => now,
new ItemCooldownAssets(sprites));
Assert.Equal(sprites[5], list.Cell.ActiveCooldownSprite());
var future = new UiItemSlot();
future.SetItem(0x5001u, 100u);
list.AddItem(future);
Assert.Equal(sprites[5], future.ActiveCooldownSprite());
now = 129.999d;
controller.Tick();
Assert.Equal(sprites[0], list.Cell.ActiveCooldownSprite());
now = 130d;
controller.Tick();
Assert.Equal(0u, list.Cell.ActiveCooldownSprite());
Assert.Equal(0, spellbook.ActiveCount);
}
}

View file

@ -92,4 +92,27 @@ public class ItemListCellTemplateTests
// m_elem_Icon ItemSlot_Empty state is the brown/gold 0x06001A97 surface. // m_elem_Icon ItemSlot_Empty state is the brown/gold 0x06001A97 surface.
Assert.Equal(0x06001A97u, sprite); Assert.Equal(0x06001A97u, sprite);
} }
[Fact]
public void Shared_UIItem_resolves_all_ten_cooldown_overlays()
{
var datDir = DatDir();
if (datDir is null) return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
ItemCooldownAssets? assets = ItemCooldownAssets.TryLoad(dats);
Assert.NotNull(assets);
Assert.Equal(ItemCooldownAssets.OverlayCount, assets.Value.Sprites.Count);
Assert.Equal(
new uint[]
{
0x060067CFu, 0x060067D0u, 0x060067D1u, 0x060067D2u, 0x060067D3u,
0x060067D4u, 0x060067D5u, 0x060067D6u, 0x060067D7u, 0x060067D8u,
},
assets.Value.Sprites);
_out.WriteLine(
string.Join(", ", assets.Value.Sprites.Select(id => $"0x{id:X8}")));
}
} }

View file

@ -5,6 +5,24 @@ namespace AcDream.App.Tests.UI;
public class UiItemListTests public class UiItemListTests
{ {
[Fact]
public void CooldownPresentation_propagates_to_existing_and_future_cells()
{
var list = new UiItemList();
uint[] sprites = Enumerable.Range(1, 10).Select(i => (uint)i).ToArray();
Func<uint, int> provider = _ => 7;
list.CooldownSprites = sprites;
list.CooldownStepProvider = provider;
var future = new UiItemSlot();
list.AddItem(future);
Assert.Same(sprites, list.Cell.CooldownSprites);
Assert.Same(provider, list.Cell.CooldownStepProvider);
Assert.Same(sprites, future.CooldownSprites);
Assert.Same(provider, future.CooldownStepProvider);
}
[Fact] [Fact]
public void IsLeafWidget() => Assert.True(new UiItemList().ConsumesDatChildren); public void IsLeafWidget() => Assert.True(new UiItemList().ConsumesDatChildren);

View file

@ -194,4 +194,36 @@ public class UiItemSlotTests
[Fact] [Fact]
public void CapacityFrontSprite_default() => Assert.Equal(0x06004D23u, new UiItemSlot().CapacityFrontSprite); public void CapacityFrontSprite_default() => Assert.Equal(0x06004D23u, new UiItemSlot().CapacityFrontSprite);
[Fact]
public void ActiveCooldownSprite_selects_the_exact_one_based_step()
{
uint[] sprites =
[
0x06000001u, 0x06000002u, 0x06000003u, 0x06000004u, 0x06000005u,
0x06000006u, 0x06000007u, 0x06000008u, 0x06000009u, 0x0600000Au,
];
var slot = new UiItemSlot
{
CooldownSprites = sprites,
CooldownStepProvider = id => id == 0x5001u ? 6 : 0,
};
slot.SetItem(0x5001u, 99u);
Assert.Equal(0x06000006u, slot.ActiveCooldownSprite());
}
[Fact]
public void ActiveCooldownSprite_isHidden_for_empty_or_inactive_slot()
{
var slot = new UiItemSlot
{
CooldownSprites = Enumerable.Range(1, 10).Select(i => (uint)i).ToArray(),
CooldownStepProvider = _ => 0,
};
Assert.Equal(0u, slot.ActiveCooldownSprite());
slot.SetItem(0x5001u, 99u);
Assert.Equal(0u, slot.ActiveCooldownSprite());
}
} }

View file

@ -625,9 +625,28 @@ public sealed class CreateObjectTests
var parsed = CreateObject.TryParse(body); var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed); Assert.NotNull(parsed);
Assert.Equal(11u, parsed.Value.CooldownId);
Assert.Equal(12.5, parsed.Value.CooldownDuration);
Assert.Equal(0x50000001u, parsed.Value.PetOwnerId); Assert.Equal(0x50000001u, parsed.Value.PetOwnerId);
} }
[Fact]
public void WeenieHeader_absentCooldownFields_remainNull()
{
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
guid: 0x50000026u,
name: "Ordinary Stone",
itemType: (uint)ItemType.Misc,
objectDescriptionFlags: 0x04000000u,
weenieFlags2: 0u);
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
Assert.Null(parsed.Value.CooldownId);
Assert.Null(parsed.Value.CooldownDuration);
}
[Fact] [Fact]
public void WeenieHeader_spellId_isPreservedForCasterEndowment() public void WeenieHeader_spellId_isPreservedForCasterEndowment()
{ {

View file

@ -66,6 +66,8 @@ public sealed class ObjectTableWiringTests
AmmoType = 1, AmmoType = 1,
PluralName = "Iron Swords", PluralName = "Iron Swords",
PetOwnerId = 0x50000001u, PetOwnerId = 0x50000001u,
CooldownId = 42u,
CooldownDuration = 30d,
}; };
var d = ObjectTableWiring.ToWeenieData(spawn); var d = ObjectTableWiring.ToWeenieData(spawn);
@ -114,6 +116,8 @@ public sealed class ObjectTableWiringTests
Assert.Equal((ushort)1, d.AmmoType); Assert.Equal((ushort)1, d.AmmoType);
Assert.Equal("Iron Swords", d.PluralName); Assert.Equal("Iron Swords", d.PluralName);
Assert.Equal(0x50000001u, d.PetOwnerId); Assert.Equal(0x50000001u, d.PetOwnerId);
Assert.Equal(42u, d.CooldownId);
Assert.Equal(30d, d.CooldownDuration);
} }
[Fact] [Fact]

View file

@ -212,6 +212,22 @@ public sealed class ClientObjectTableTests
Assert.Equal("desc", item.Properties.Strings[100]); Assert.Equal("desc", item.Properties.Strings[100]);
} }
[Fact]
public void UpdateProperties_projects_assessed_shared_cooldown_metadata()
{
var repo = new ClientObjectTable();
var item = MakeItem(100);
repo.AddOrUpdate(item);
var patch = new PropertyBundle();
patch.Ints[ClientObjectTable.SharedCooldownPropertyId] = 42;
patch.Floats[ClientObjectTable.CooldownDurationPropertyId] = 30d;
repo.UpdateProperties(100, patch);
Assert.Equal(42u, item.CooldownId);
Assert.Equal(30d, item.CooldownDuration);
}
[Fact] [Fact]
public void Clear_RemovesAllItems() public void Clear_RemovesAllItems()
{ {

View file

@ -0,0 +1,22 @@
using AcDream.Core.Items;
namespace AcDream.Core.Tests.Items;
public sealed class ItemCooldownDisplayTests
{
[Theory]
[InlineData(10.0, 10.0, 10)]
[InlineData(10.0, 9.0, 10)]
[InlineData(10.0, 8.999, 9)]
[InlineData(10.0, 5.0, 6)]
[InlineData(10.0, 0.001, 1)]
[InlineData(10.0, 0.0, 0)]
[InlineData(0.0, 5.0, 0)]
public void GetOverlayStep_matches_retail_truncation(
double duration,
double remaining,
int expected)
=> Assert.Equal(
expected,
ItemCooldownDisplay.GetOverlayStep(duration, remaining));
}

View file

@ -160,6 +160,87 @@ public sealed class SpellbookTests
Assert.Equal(0, book.ActiveCount); Assert.Equal(0, book.ActiveCount);
} }
[Fact]
public void OnCooldown_removes_first_expired_match_then_reveals_next_record()
{
var book = new Spellbook();
var expired = new ActiveEnchantmentRecord(
SpellId: 0x802Au,
LayerId: 1u,
Duration: 5d,
CasterGuid: 0u,
Bucket: Spellbook.CooldownBucket,
StartTime: 0d);
var active = expired with
{
LayerId = 2u,
Duration = 30d,
StartTime = 10d,
};
book.ReplaceManifest(
new Dictionary<uint, float>(),
[expired, active],
Array.Empty<IReadOnlyList<uint>>(),
Array.Empty<(uint Id, uint Amount)>(),
0u);
ActiveEnchantmentRecord? removed = null;
book.EnchantmentRemoved += record => removed = record;
Assert.False(book.OnCooldown(
0x2Au,
currentTime: 20d,
out double expiredRemaining));
Assert.Equal(-15d, expiredRemaining);
Assert.Equal(expired, removed);
Assert.Equal(1, book.ActiveCount);
Assert.True(book.OnCooldown(
0x2Au,
currentTime: 20d,
out double activeRemaining));
Assert.Equal(20d, activeRemaining);
}
[Fact]
public void OnCooldown_ignores_same_spell_outside_cooldown_registry()
{
var book = new Spellbook();
book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 0x802Au,
LayerId: 1u,
Duration: 30d,
CasterGuid: 0u,
Bucket: 1u,
StartTime: 10d));
Assert.False(book.OnCooldown(
0x2Au,
currentTime: 20d,
out double remaining));
Assert.Equal(0d, remaining);
Assert.Equal(1, book.ActiveCount);
}
[Fact]
public void OnCooldown_zero_group_is_inactive_and_does_not_remove()
{
var book = new Spellbook();
book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 0x8000u,
LayerId: 1u,
Duration: 30d,
CasterGuid: 0u,
Bucket: Spellbook.CooldownBucket,
StartTime: 10d));
Assert.False(book.OnCooldown(
cooldownId: 0u,
currentTime: 20d,
out double remaining));
Assert.Equal(0d, remaining);
Assert.Equal(1, book.ActiveCount);
}
[Fact] [Fact]
public void OnPurgeAll_RemovesOnlyFiniteMultAndAddEnchantments() public void OnPurgeAll_RemovesOnlyFiniteMultAndAddEnchantments()
{ {