diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 7443bf43..88ba2b87 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -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
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-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-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)
diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index 5c3e6169..24bca409 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -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
immediately, while the registered AD-27 approach seam remains world-only;
shared AutoWear reports the exact overlapping worn item that must be removed.
-That correction awaits its connected gate; the magnifying-glass Assess command
-follows in the same slice.
+That correction is user-confirmed. The requested adjacent cooldown follow-up
+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.
---
diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md
index 34fa9844..769541ff 100644
--- a/docs/plans/2026-05-12-milestones.md
+++ b/docs/plans/2026-05-12-milestones.md
@@ -92,13 +92,12 @@ broaden the feature surface.
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
Use/AutoWield/use-on-target command routing passed their connected gate.
-The follow-up retail correction for zero-valued direct-use items and named
-AutoWear blockers is code-complete. Its first connected gate exposed and
-corrected the lower delivery boundary: carried direct-use had been sent through
-the world approach query even though inventory objects have no spatial entity.
-Owned Use now sends immediately; world Use alone retains the registered AD-27
-approach seam. The combined correction awaits its connected gate before the
-Assess half continues.
+The follow-up retail correction for zero-valued direct-use items, named
+AutoWear blockers, and carried-use delivery is user-confirmed. The requested
+adjacent cooldown follow-up now preserves the item's shared group/duration,
+projects the authoritative cooldown-enchantment bucket, and draws retail's ten
+DAT-authored radial steps across all item-slot surfaces. Its visual gate is
+next; the Assess half then continues.
Carried:
#153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus
diff --git a/docs/plans/2026-07-23-world-interaction-completion.md b/docs/plans/2026-07-23-world-interaction-completion.md
index 3bd013a1..d5d5d4ab 100644
--- a/docs/plans/2026-07-23-world-interaction-completion.md
+++ b/docs/plans/2026-07-23-world-interaction-completion.md
@@ -1,8 +1,9 @@
# World interaction completion — pre-M4 program
-**Status:** Slice 1 user-accepted 2026-07-23. Slice 2 Use-hand selection state
-passed its connected gate. The follow-up zero-useability/AutoWear correction is
-code-complete and awaits its connected gate before Assess continues.
+**Status:** Slice 1 user-accepted 2026-07-23. Slice 2 Use-hand selection,
+zero-useability, carried direct-use, and AutoWear correction passed their
+connected gates. The requested shared item-cooldown follow-up is code-complete
+and awaits its visual gate before Assess continues.
**Milestone:** M4 prerequisite/preamble.
**Architecture:** retained gameplay UI over shared selection, object, and
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
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.
+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
@@ -168,9 +185,9 @@ Named retail references and executable pseudocode are recorded in
### 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.
-- 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
status-control gate.
diff --git a/docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md b/docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md
index ba815855..127f077d 100644
--- a/docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md
+++ b/docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md
@@ -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_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_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) |
**The four named LayoutDesc states** that drive `m_elem_Icon` / `m_elem_Icon_DragAccept`
diff --git a/docs/research/2026-07-23-retail-item-cooldown-pseudocode.md b/docs/research/2026-07-23-retail-item-cooldown-pseudocode.md
new file mode 100644
index 00000000..deb79660
--- /dev/null
+++ b/docs/research/2026-07-23-retail-item-cooldown-pseudocode.md
@@ -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.
diff --git a/src/AcDream.App/UI/Layout/ItemCooldownAssets.cs b/src/AcDream.App/UI/Layout/ItemCooldownAssets.cs
new file mode 100644
index 00000000..76a635c1
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/ItemCooldownAssets.cs
@@ -0,0 +1,56 @@
+using AcDream.Content;
+using DatReaderWriter;
+
+namespace AcDream.App.UI.Layout;
+
+///
+/// DAT-authored ten-step radial cooldown art owned by the shared retail
+/// UIElement_UIItem prototype.
+///
+public readonly record struct ItemCooldownAssets(IReadOnlyList 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;
+ }
+}
diff --git a/src/AcDream.App/UI/Layout/ItemCooldownUiController.cs b/src/AcDream.App/UI/Layout/ItemCooldownUiController.cs
new file mode 100644
index 00000000..a0c4037c
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/ItemCooldownUiController.cs
@@ -0,0 +1,101 @@
+using AcDream.Core.Items;
+using AcDream.Core.Spells;
+
+namespace AcDream.App.UI.Layout;
+
+///
+/// Shared retained-UI owner for retail item cooldowns. It performs the retail
+/// heartbeat before drawing and gives every current and future
+/// the same display projection.
+///
+public sealed class ItemCooldownUiController
+{
+ private readonly Spellbook _spellbook;
+ private readonly ClientObjectTable _objects;
+ private readonly Func _currentTime;
+ private readonly List _lists = new();
+ private readonly Dictionary _stepByItemId = new();
+
+ private ItemCooldownUiController(
+ Spellbook spellbook,
+ ClientObjectTable objects,
+ Func currentTime)
+ {
+ _spellbook = spellbook;
+ _objects = objects;
+ _currentTime = currentTime;
+ }
+
+ public static ItemCooldownUiController Bind(
+ UiElement root,
+ Spellbook spellbook,
+ ClientObjectTable objects,
+ Func 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 sprites)
+ {
+ if (element is UiItemList list)
+ {
+ _lists.Add(list);
+ list.CooldownSprites = sprites;
+ list.CooldownStepProvider = GetOverlayStep;
+ }
+
+ foreach (UiElement child in element.Children)
+ Configure(child, sprites);
+ }
+}
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index 390c5236..757b3f51 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -198,6 +198,7 @@ public sealed class RetailUiRuntime : IDisposable
private RetailItemConfirmationController? _itemConfirmationController;
private RetailSkillTrainingConfirmationController? _skillTrainingConfirmationController;
private UiShortcutDigitGraphics? _shortcutDigitGraphics;
+ private ItemCooldownUiController? _itemCooldownController;
private VividTargetIndicatorController? _vividTargetIndicator;
private ResourceShutdownTransaction? _shutdown;
private bool _disposed;
@@ -242,6 +243,7 @@ public sealed class RetailUiRuntime : IDisposable
MountPlugins();
MountInventory();
MountExternalContainer();
+ MountItemCooldowns();
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
BindToolbarPanelButtons();
SyncToolbarWindowButtons();
@@ -346,6 +348,7 @@ public sealed class RetailUiRuntime : IDisposable
JumpPowerbarController?.Tick();
SelectedObjectController?.Tick(deltaSeconds);
ExternalContainerController?.Tick();
+ _itemCooldownController?.Tick();
DialogFactory?.Tick();
Host.Tick(deltaSeconds);
_automation?.Tick(deltaSeconds);
@@ -1760,6 +1763,29 @@ public sealed class RetailUiRuntime : IDisposable
"[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()
{
if (_disposed)
diff --git a/src/AcDream.App/UI/UiItemList.cs b/src/AcDream.App/UI/UiItemList.cs
index a2d0fa0d..39b1998a 100644
--- a/src/AcDream.App/UI/UiItemList.cs
+++ b/src/AcDream.App/UI/UiItemList.cs
@@ -39,6 +39,30 @@ public sealed class UiItemList : UiElement
public Func? SpriteResolve { get; set; }
+ private IReadOnlyList? _cooldownSprites;
+ public IReadOnlyList? CooldownSprites
+ {
+ get => _cooldownSprites;
+ set
+ {
+ _cooldownSprites = value;
+ foreach (UiItemSlot cell in _cells)
+ cell.CooldownSprites = value;
+ }
+ }
+
+ private Func? _cooldownStepProvider;
+ public Func? CooldownStepProvider
+ {
+ get => _cooldownStepProvider;
+ set
+ {
+ _cooldownStepProvider = value;
+ foreach (UiItemSlot cell in _cells)
+ cell.CooldownStepProvider = value;
+ }
+ }
+
///
/// Optional non-weenie catalog drop target. Used by retail spell shortcuts;
/// physical-item drops continue through .
@@ -92,6 +116,8 @@ public sealed class UiItemList : UiElement
public void AddItem(UiItemSlot cell)
{
cell.SpriteResolve ??= SpriteResolve;
+ cell.CooldownSprites ??= _cooldownSprites;
+ cell.CooldownStepProvider ??= _cooldownStepProvider;
if (_cellEmptySprite != 0) cell.EmptySprite = _cellEmptySprite;
// The list lays cells out procedurally (grid offset + scroll clip), so cells must be
// 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();
cell.SpriteResolve ??= SpriteResolve;
+ cell.CooldownSprites ??= _cooldownSprites;
+ cell.CooldownStepProvider ??= _cooldownStepProvider;
if (_cellEmptySprite != 0) cell.EmptySprite = _cellEmptySprite;
cell.Anchors = AnchorEdges.None;
_cells.Add(cell);
diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs
index 2d929f83..963b604c 100644
--- a/src/AcDream.App/UI/UiItemSlot.cs
+++ b/src/AcDream.App/UI/UiItemSlot.cs
@@ -103,6 +103,18 @@ public class UiItemSlot : UiElement
/// RenderSurface id -> (GL texture, w, h). Set by the factory/controller.
public Func? SpriteResolve { get; set; }
+ ///
+ /// DAT-authored 10%..100% radial cooldown overlays from element ids
+ /// 0x1000054F..0x10000558.
+ ///
+ public IReadOnlyList? CooldownSprites { get; set; }
+
+ ///
+ /// Bound by the shared cooldown controller. Returns 0 for hidden or 1..10
+ /// for the exact retail overlay step.
+ ///
+ public Func? CooldownStepProvider { get; set; }
+
public void SetItem(
uint itemId,
uint iconTexture,
@@ -387,6 +399,28 @@ public class UiItemSlot : UiElement
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);
+ }
}
/// Draws the shared retail shortcut-number layer. Catalog spell cells
@@ -409,4 +443,17 @@ public class UiItemSlot : UiElement
if (texture != 0)
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;
+ }
}
diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs
index 530f03ac..1babe390 100644
--- a/src/AcDream.Core.Net/Messages/CreateObject.cs
+++ b/src/AcDream.Core.Net/Messages/CreateObject.cs
@@ -210,6 +210,11 @@ public static class CreateObject
// PublicWeenieDesc._spellID, gated by PWD_Packed_SpellID. The packed
// wire field is u16 and widens into retail's u32 runtime member.
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
// legacy convenience fields while live-entity ownership migrates to
// LiveEntityRuntime in Step 2.
@@ -871,6 +876,8 @@ public static class CreateObject
uint uiEffects = 0;
uint weenieFlags2 = 0;
uint? petOwnerId = null;
+ uint? cooldownId = null;
+ double? cooldownDuration = null;
try
{
// BF_INCLUDES_SECOND_HEADER = 0x04000000 per acclient.h:6458
@@ -1075,11 +1082,12 @@ public static class CreateObject
if ((weenieFlags2 & 0x00000002u) != 0) // CooldownId u32
{
if (body.Length - pos < 4) throw new FormatException("trunc CooldownId");
- pos += 4;
+ cooldownId = ReadU32(body, ref pos);
}
if ((weenieFlags2 & 0x00000004u) != 0) // CooldownDuration f64
{
if (body.Length - pos < 8) throw new FormatException("trunc CooldownDuration");
+ cooldownDuration = BinaryPrimitives.ReadDoubleLittleEndian(body.Slice(pos));
pos += 8;
}
if ((weenieFlags2 & 0x00000008u) != 0) // PetOwner u32
@@ -1119,6 +1127,8 @@ public static class CreateObject
PetOwnerId: petOwnerId,
AmmoType: ammoType,
SpellId: spellId,
+ CooldownId: cooldownId,
+ CooldownDuration: cooldownDuration,
Physics: physics);
}
catch
diff --git a/src/AcDream.Core.Net/ObjectTableWiring.cs b/src/AcDream.Core.Net/ObjectTableWiring.cs
index 17ba4e5b..acab8097 100644
--- a/src/AcDream.Core.Net/ObjectTableWiring.cs
+++ b/src/AcDream.Core.Net/ObjectTableWiring.cs
@@ -184,5 +184,7 @@ public static class ObjectTableWiring
PluralName: s.PluralName,
PetOwnerId: s.PetOwnerId,
AmmoType: s.AmmoType,
- SpellId: s.SpellId);
+ SpellId: s.SpellId,
+ CooldownId: s.CooldownId,
+ CooldownDuration: s.CooldownDuration);
}
diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs
index e000ee0d..bc2acb89 100644
--- a/src/AcDream.Core.Net/WorldSession.cs
+++ b/src/AcDream.Core.Net/WorldSession.cs
@@ -154,6 +154,8 @@ public sealed class WorldSession : IDisposable
uint? PetOwnerId = null,
ushort? AmmoType = null,
uint? SpellId = null,
+ uint? CooldownId = null,
+ double? CooldownDuration = null,
PhysicsSpawnData? Physics = null);
///
@@ -215,6 +217,8 @@ public sealed class WorldSession : IDisposable
PetOwnerId: parsed.PetOwnerId,
AmmoType: parsed.AmmoType,
SpellId: parsed.SpellId,
+ CooldownId: parsed.CooldownId,
+ CooldownDuration: parsed.CooldownDuration,
Physics: parsed.Physics);
/// Fires when the session finishes parsing a CreateObject.
diff --git a/src/AcDream.Core/Items/ClientObject.cs b/src/AcDream.Core/Items/ClientObject.cs
index 3e2ff8ab..2953aeb7 100644
--- a/src/AcDream.Core/Items/ClientObject.cs
+++ b/src/AcDream.Core/Items/ClientObject.cs
@@ -193,6 +193,17 @@ public sealed class ClientObject
public ushort? AmmoType { get; set; }
/// Retail PublicWeenieDesc._spellID; used by caster endowments.
public uint? SpellId { get; set; }
+ ///
+ /// Retail PublicWeenieDesc._cooldown_id. Positive values name a
+ /// shared item-cooldown group whose player enchantment id is
+ /// CooldownId + 0x8000.
+ ///
+ public uint? CooldownId { get; set; }
+ ///
+ /// Retail PublicWeenieDesc._cooldown_duration, in seconds. This is
+ /// the denominator used by UIElement_UIItem::UpdateCooldownDisplay.
+ ///
+ public double? CooldownDuration { get; set; }
/// Client-side trade state used by ItemHolder legality gates.
public int TradeState { get; set; }
/// Resolved membership in SpellComponentTable (retail IsComponentPack).
@@ -261,7 +272,9 @@ public readonly record struct WeenieData(
string? PluralName = null,
uint? PetOwnerId = null,
ushort? AmmoType = null,
- uint? SpellId = null);
+ uint? SpellId = null,
+ uint? CooldownId = null,
+ double? CooldownDuration = null);
///
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).
diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs
index 3a5c3d87..9663150c 100644
--- a/src/AcDream.Core/Items/ClientObjectTable.cs
+++ b/src/AcDream.Core/Items/ClientObjectTable.cs
@@ -194,6 +194,8 @@ public sealed class ClientObjectTable
/// .
public const uint UiEffectsPropertyId = 18u;
public const uint CurrentWieldedLocationPropertyId = 10u;
+ public const uint SharedCooldownPropertyId = 280u;
+ public const uint CooldownDurationPropertyId = 167u;
public int ObjectCount => _objects.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.DataIds) item.Properties.DataIds[kv.Key] = kv.Value;
foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value;
+ ApplyCooldownProperties(item, incoming);
ObjectUpdated?.Invoke(item);
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.DataIds) item.Properties.DataIds[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);
}
@@ -656,6 +660,7 @@ public sealed class ClientObjectTable
ClientObjectPlacement previous = ClientObjectPlacement.From(item);
item.Properties.Ints[propertyId] = value;
if (propertyId == UiEffectsPropertyId) item.Effects = (uint)value;
+ if (propertyId == SharedCooldownPropertyId) item.CooldownId = (uint)value;
if (propertyId == CurrentWieldedLocationPropertyId)
item.CurrentlyEquippedLocation = (EquipMask)(uint)value;
if (propertyId == CurrentWieldedLocationPropertyId)
@@ -664,6 +669,20 @@ public sealed class ClientObjectTable
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;
+ }
+
///
/// Apply a single PropertyInt64 update to an object's bundle and fire
/// 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.AmmoType is { } ammoType) obj.AmmoType = ammoType;
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.RadarBehavior is { } radarBehavior) obj.RadarBehavior = radarBehavior;
if (d.ItemsCapacity is { } ic) obj.ItemsCapacity = ic;
diff --git a/src/AcDream.Core/Items/ItemCooldownDisplay.cs b/src/AcDream.Core/Items/ItemCooldownDisplay.cs
new file mode 100644
index 00000000..88ee4cbe
--- /dev/null
+++ b/src/AcDream.Core/Items/ItemCooldownDisplay.cs
@@ -0,0 +1,31 @@
+namespace AcDream.Core.Items;
+
+///
+/// Retail item-cooldown projection shared by inventory, equipment, and shortcut
+/// UIItems.
+///
+///
+/// Port of UIElement_UIItem::UpdateCooldownDisplay @ 0x004E1E20.
+/// The remaining fraction selects one of ten authored radial overlays.
+///
+///
+public static class ItemCooldownDisplay
+{
+ ///
+ /// Retail's exact ten-step display formula:
+ /// trunc((remaining / itemDuration) * 10 + 1). Results 1..9 select
+ /// the 10%..90% art, while 10 or above selects the 100% art.
+ ///
+ 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;
+ }
+}
diff --git a/src/AcDream.Core/Spells/Spellbook.cs b/src/AcDream.Core/Spells/Spellbook.cs
index 6600126c..28d7819c 100644
--- a/src/AcDream.Core/Spells/Spellbook.cs
+++ b/src/AcDream.Core/Spells/Spellbook.cs
@@ -17,6 +17,9 @@ namespace AcDream.Core.Spells;
///
public sealed class Spellbook
{
+ public const uint CooldownSpellOffset = 0x8000u;
+ public const uint CooldownBucket = 8u;
+
private readonly Dictionary _learnedSpells = new();
private readonly Dictionary _activeById = new();
private readonly Dictionary> _enchantmentOrderByBucket = new();
@@ -240,6 +243,46 @@ public sealed class Spellbook
if (changed) NotifyEnchantmentsChanged();
}
+ ///
+ /// Retail CEnchantmentRegistry::OnCooldown @ 0x005943C0.
+ /// Scans the cooldown list head-to-tail, stops at the first shared-group
+ /// match, and removes that node when its monotonic lifetime has expired.
+ ///
+ public bool OnCooldown(
+ uint cooldownId,
+ double currentTime,
+ out double remaining)
+ {
+ remaining = 0d;
+ if (cooldownId == 0u
+ || !_enchantmentOrderByBucket.TryGetValue(
+ CooldownBucket,
+ out List? 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;
+ }
+
///
/// Retail EnchantmentRegistry::PurgeEnchantments (0x00594DE0): purge only
/// finite-duration multiplicative/additive enchantments. Vitae, cooldowns,
diff --git a/tests/AcDream.App.Tests/UI/Layout/ItemCooldownUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ItemCooldownUiControllerTests.cs
new file mode 100644
index 00000000..3acff0d2
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/ItemCooldownUiControllerTests.cs
@@ -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);
+ }
+}
diff --git a/tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs b/tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs
index 82600811..635364b9 100644
--- a/tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs
@@ -92,4 +92,27 @@ public class ItemListCellTemplateTests
// m_elem_Icon ItemSlot_Empty state is the brown/gold 0x06001A97 surface.
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}")));
+ }
+
}
diff --git a/tests/AcDream.App.Tests/UI/UiItemListTests.cs b/tests/AcDream.App.Tests/UI/UiItemListTests.cs
index 344c2605..8eb9d9ba 100644
--- a/tests/AcDream.App.Tests/UI/UiItemListTests.cs
+++ b/tests/AcDream.App.Tests/UI/UiItemListTests.cs
@@ -5,6 +5,24 @@ namespace AcDream.App.Tests.UI;
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 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]
public void IsLeafWidget() => Assert.True(new UiItemList().ConsumesDatChildren);
diff --git a/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs b/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs
index 46df0568..de5b6946 100644
--- a/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs
+++ b/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs
@@ -194,4 +194,36 @@ public class UiItemSlotTests
[Fact]
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());
+ }
}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs
index 86e971bb..1a61e14c 100644
--- a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs
+++ b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs
@@ -625,9 +625,28 @@ public sealed class CreateObjectTests
var parsed = CreateObject.TryParse(body);
Assert.NotNull(parsed);
+ Assert.Equal(11u, parsed.Value.CooldownId);
+ Assert.Equal(12.5, parsed.Value.CooldownDuration);
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]
public void WeenieHeader_spellId_isPreservedForCasterEndowment()
{
diff --git a/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs b/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs
index 9bc6a0e7..d0707352 100644
--- a/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs
+++ b/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs
@@ -66,6 +66,8 @@ public sealed class ObjectTableWiringTests
AmmoType = 1,
PluralName = "Iron Swords",
PetOwnerId = 0x50000001u,
+ CooldownId = 42u,
+ CooldownDuration = 30d,
};
var d = ObjectTableWiring.ToWeenieData(spawn);
@@ -114,6 +116,8 @@ public sealed class ObjectTableWiringTests
Assert.Equal((ushort)1, d.AmmoType);
Assert.Equal("Iron Swords", d.PluralName);
Assert.Equal(0x50000001u, d.PetOwnerId);
+ Assert.Equal(42u, d.CooldownId);
+ Assert.Equal(30d, d.CooldownDuration);
}
[Fact]
diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs
index d4517cf1..2bc2113c 100644
--- a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs
+++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs
@@ -212,6 +212,22 @@ public sealed class ClientObjectTableTests
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]
public void Clear_RemovesAllItems()
{
diff --git a/tests/AcDream.Core.Tests/Items/ItemCooldownDisplayTests.cs b/tests/AcDream.Core.Tests/Items/ItemCooldownDisplayTests.cs
new file mode 100644
index 00000000..5b4c73cf
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Items/ItemCooldownDisplayTests.cs
@@ -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));
+
+}
diff --git a/tests/AcDream.Core.Tests/Spells/SpellbookTests.cs b/tests/AcDream.Core.Tests/Spells/SpellbookTests.cs
index 513745eb..54a7d60a 100644
--- a/tests/AcDream.Core.Tests/Spells/SpellbookTests.cs
+++ b/tests/AcDream.Core.Tests/Spells/SpellbookTests.cs
@@ -160,6 +160,87 @@ public sealed class SpellbookTests
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(),
+ [expired, active],
+ Array.Empty>(),
+ 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]
public void OnPurgeAll_RemovesOnlyFiniteMultAndAddEnchantments()
{