perf(ui): scope cooldown heartbeat to visible items

This commit is contained in:
Erik 2026-07-25 05:12:58 +02:00
parent f2a015be8e
commit 6b56f4bef2
5 changed files with 132 additions and 3 deletions

View file

@ -45,7 +45,7 @@ and repeated stable provider polls allocate no report/wrap objects.
Landed evidence: Landed evidence:
[`../research/2026-07-25-slice-h-a1-ui-text-cache.md`](../research/2026-07-25-slice-h-a1-ui-text-cache.md). [`../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 1. Keep the shared retail heartbeat and one cooldown result per represented
item. item.
@ -59,6 +59,9 @@ Gate: hidden inventory, equipment, external-container, vendor, and spell-bar
lists are not scanned; reopening immediately displays the authoritative lists are not scanned; reopening immediately displays the authoritative
cooldown step. 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 ### H-a3 — equipped-child transition work
1. Preserve both frame ordering points: pre-network presentation and 1. Preserve both frame ordering points: pre-network presentation and

View file

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

View file

@ -49,14 +49,22 @@ public sealed class ItemCooldownUiController
public void Tick() public void Tick()
{ {
double now = _currentTime();
_stepByItemId.Clear(); _stepByItemId.Clear();
if (!_spellbook.HasCooldownEnchantments)
return;
double now = _currentTime();
// Retail updates cooldown visibility from UIElement_UIItem::DoHeartbeat, // Retail updates cooldown visibility from UIElement_UIItem::DoHeartbeat,
// not while drawing. Query each represented item once so an expired // 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) foreach (UiItemList list in _lists)
{ {
if (!IsEffectivelyVisible(list))
continue;
for (int index = 0; index < list.GetNumUIItems(); index++) for (int index = 0; index < list.GetNumUIItems(); index++)
{ {
UiItemSlot? cell = list.GetItem(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) internal int GetOverlayStep(uint itemId)
=> _stepByItemId.TryGetValue(itemId, out int step) ? step : 0; => _stepByItemId.TryGetValue(itemId, out int step) ? step : 0;

View file

@ -156,6 +156,11 @@ public sealed class Spellbook
public int LearnedCount => _learnedSpells.Count; public int LearnedCount => _learnedSpells.Count;
public int ActiveCount => _activeById.Count; public int ActiveCount => _activeById.Count;
public bool HasCooldownEnchantments =>
_enchantmentOrderByBucket.TryGetValue(
CooldownBucket,
out List<uint>? order)
&& order.Count != 0;
public bool Knows(uint spellId) => _learnedSpells.ContainsKey(spellId); public bool Knows(uint spellId) => _learnedSpells.ContainsKey(spellId);

View file

@ -59,4 +59,82 @@ public sealed class ItemCooldownUiControllerTests
Assert.Equal(0u, list.Cell.ActiveCooldownSprite()); Assert.Equal(0u, list.Cell.ActiveCooldownSprite());
Assert.Equal(0, spellbook.ActiveCount); 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());
}
} }