diff --git a/docs/plans/2026-07-25-modern-runtime-slice-h.md b/docs/plans/2026-07-25-modern-runtime-slice-h.md index 5deffbb6..37a8b0b2 100644 --- a/docs/plans/2026-07-25-modern-runtime-slice-h.md +++ b/docs/plans/2026-07-25-modern-runtime-slice-h.md @@ -45,7 +45,7 @@ and repeated stable provider polls allocate no report/wrap objects. Landed evidence: [`../research/2026-07-25-slice-h-a1-ui-text-cache.md`](../research/2026-07-25-slice-h-a1-ui-text-cache.md). -### H-a2 — visible cooldown participants +### H-a2 — visible cooldown participants — COMPLETE 1. Keep the shared retail heartbeat and one cooldown result per represented item. @@ -59,6 +59,9 @@ Gate: hidden inventory, equipment, external-container, vendor, and spell-bar lists are not scanned; reopening immediately displays the authoritative cooldown step. +Landed evidence: +[`../research/2026-07-25-slice-h-a2-cooldown-scope.md`](../research/2026-07-25-slice-h-a2-cooldown-scope.md). + ### H-a3 — equipped-child transition work 1. Preserve both frame ordering points: pre-network presentation and diff --git a/docs/research/2026-07-25-slice-h-a2-cooldown-scope.md b/docs/research/2026-07-25-slice-h-a2-cooldown-scope.md new file mode 100644 index 00000000..edad298a --- /dev/null +++ b/docs/research/2026-07-25-slice-h-a2-cooldown-scope.md @@ -0,0 +1,24 @@ +# Slice H-a2 — retained cooldown heartbeat scope + +Retail updates item cooldown children from +`UIElement_UIItem::DoHeartbeat @ 0x004E1DF0`. Hidden retained subtrees do not +receive that heartbeat. + +The shared acdream controller now keeps the same one-result-per-item heartbeat, +but: + +- returns before reading the clock or visiting a slot when the authoritative + enchantment registry has no cooldown entries; +- visits a mounted `UiItemList` only when the list and every ancestor are + visible; +- clears the frame's step projection first, so a hidden-only item cannot retain + a stale ring; +- recomputes the authoritative step on the first heartbeat after the panel + becomes visible; +- retains existing future-slot and shared-cooldown-group behavior. + +Focused tests cover shared groups/future cells, hidden-parent suppression and +reopen, and the zero-work empty-registry path. + +Release gate: 3,781 App tests passed / 3 skipped; 8,265 complete-solution +tests passed / 5 skipped. diff --git a/src/AcDream.App/UI/Layout/ItemCooldownUiController.cs b/src/AcDream.App/UI/Layout/ItemCooldownUiController.cs index a0c4037c..c4ed53a6 100644 --- a/src/AcDream.App/UI/Layout/ItemCooldownUiController.cs +++ b/src/AcDream.App/UI/Layout/ItemCooldownUiController.cs @@ -49,14 +49,22 @@ public sealed class ItemCooldownUiController public void Tick() { - double now = _currentTime(); _stepByItemId.Clear(); + if (!_spellbook.HasCooldownEnchantments) + return; + + double now = _currentTime(); // 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. + // registry node can be removed safely before the render walk. A hidden + // retained subtree receives no heartbeat in retail, so only lists whose + // complete ancestry is visible participate. foreach (UiItemList list in _lists) { + if (!IsEffectivelyVisible(list)) + continue; + for (int index = 0; index < list.GetNumUIItems(); index++) { UiItemSlot? cell = list.GetItem(index); @@ -83,6 +91,17 @@ public sealed class ItemCooldownUiController } } + internal static bool IsEffectivelyVisible(UiElement element) + { + ArgumentNullException.ThrowIfNull(element); + for (UiElement? current = element; current is not null; current = current.Parent) + { + if (!current.Visible) + return false; + } + return true; + } + internal int GetOverlayStep(uint itemId) => _stepByItemId.TryGetValue(itemId, out int step) ? step : 0; diff --git a/src/AcDream.Core/Spells/Spellbook.cs b/src/AcDream.Core/Spells/Spellbook.cs index 28d7819c..80b00b00 100644 --- a/src/AcDream.Core/Spells/Spellbook.cs +++ b/src/AcDream.Core/Spells/Spellbook.cs @@ -156,6 +156,11 @@ public sealed class Spellbook public int LearnedCount => _learnedSpells.Count; public int ActiveCount => _activeById.Count; + public bool HasCooldownEnchantments => + _enchantmentOrderByBucket.TryGetValue( + CooldownBucket, + out List? order) + && order.Count != 0; public bool Knows(uint spellId) => _learnedSpells.ContainsKey(spellId); diff --git a/tests/AcDream.App.Tests/UI/Layout/ItemCooldownUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ItemCooldownUiControllerTests.cs index 3acff0d2..6a5d2a81 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ItemCooldownUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ItemCooldownUiControllerTests.cs @@ -59,4 +59,82 @@ public sealed class ItemCooldownUiControllerTests Assert.Equal(0u, list.Cell.ActiveCooldownSprite()); Assert.Equal(0, spellbook.ActiveCount); } + + [Fact] + public void Hidden_ancestor_removes_list_from_retail_heartbeat_scope() + { + 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)); + var root = new UiPanel(); + var window = new UiPanel(); + var list = new UiItemList(); + list.Cell.SetItem(0x5001u, 99u); + root.AddChild(window); + window.AddChild(list); + uint[] sprites = Enumerable.Range(1, 10) + .Select(index => 0x06000000u + (uint)index) + .ToArray(); + ItemCooldownUiController controller = ItemCooldownUiController.Bind( + root, + spellbook, + objects, + () => 112.5d, + new ItemCooldownAssets(sprites)); + Assert.Equal(sprites[5], list.Cell.ActiveCooldownSprite()); + + window.Visible = false; + controller.Tick(); + Assert.Equal(0u, list.Cell.ActiveCooldownSprite()); + + window.Visible = true; + controller.Tick(); + Assert.Equal(sprites[5], list.Cell.ActiveCooldownSprite()); + } + + [Fact] + public void Empty_cooldown_registry_skips_clock_and_item_walk() + { + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = 0x5001u, + CooldownId = 42u, + CooldownDuration = 30d, + }); + var spellbook = new Spellbook(); + var root = new UiPanel(); + var list = new UiItemList(); + list.Cell.SetItem(0x5001u, 99u); + root.AddChild(list); + int clockReads = 0; + ItemCooldownUiController controller = ItemCooldownUiController.Bind( + root, + spellbook, + objects, + () => + { + clockReads++; + return 100d; + }, + new ItemCooldownAssets( + Enumerable.Range(1, 10).Select(index => (uint)index).ToArray())); + + controller.Tick(); + + Assert.Equal(0, clockReads); + Assert.Equal(0u, list.Cell.ActiveCooldownSprite()); + } }