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() {