ui: memoise the texture-existence probe, as its two neighbours already are

`set_texture_probe`'s closure answers every path form of `SetTexture`, and it
was the only one of the three probes installed beside it without a cache —
`set_texture_size_probe` and `set_font_probe` both memoise, and the font one's
comment already gives the reason that applies here verbatim: a path's answer
cannot change, and an addon may ask every frame.

A miss is the expensive answer. An addon texture is never in the MPQ chain, so
both candidates fall through to `loose_addon_file`, which walks the path one
component at a time and, whenever a component's real spelling is not the
lower-cased one it was handed, enumerates that whole directory to compare
case-insensitively. `Interface\AddOns` holds 94 folders here and `pfquest` never
matches `pfQuest` on a case-sensitive filesystem, so an uncached call read that
directory twice over, on the main thread, between two systems.

grom's 2026-10-21 17:08 session priced it: `[ui-verbs]` reports
`texture:SetTexture` at **45-59 us a call across all 33 windows** — stable
enough to be a fixed charge rather than a tail — against the ~0.2 us a cached
existence answer costs. 1.75 ms a frame over 34 calls while the world map was
up, because `WorldMapFrame_Update` re-textures every overlay tile and POI on
each `WORLD_MAP_UPDATE`. Replaying the same walk over the real AddOns tree
reproduces 26-42 us for a single candidate.

Misses are cached too, as the size probe's are: an addon probing for optional
art is the common case and it is the one that pays for the walk.

This is the smaller half of the map's cost — `scrollframe.rs`' derivations were
the larger — and it is a ~1-2% of frame fix, not the dip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-21 17:47:07 -06:00
co-authored by Claude Opus 5
parent e6ae2e0f67
commit cd6731799b
+29 -2
View File
@@ -84,13 +84,40 @@ fn install_addon_asset_resolvers(world: &mut World, script: &mut UiScript) {
let size_root = root.clone();
let font_chain = chain.clone();
let font_root = root.clone();
// Memoised on the same grounds as the font probe below, and it matters more: this one answers
// every path form of `SetTexture`, which is among the most-called verbs in the interface, and
// a miss is the expensive answer. An addon texture is never in the MPQ chain, so both
// candidates fall through to [`benilla_assets::loose_addon_file`], which walks the path one
// component at a time and, whenever a component's real spelling is not the lower-cased one it
// is handed, falls back to enumerating that whole directory to compare case-insensitively.
// `Interface\AddOns` holds 94 folders here and `pfquest` never matches `pfQuest` on a
// case-sensitive filesystem, so an uncached call read that directory twice over.
//
// grom's 2026-10-21 17:08 session priced it: `[ui-verbs]` reports `texture:SetTexture` at
// **45-59 us a call across all 33 windows** — 1.75 ms a frame over 34 calls while the world
// map was up, against the 0.2-0.3 us a cached existence answer costs. The map is the worst of
// it because `WorldMapFrame_Update` re-textures every overlay tile and POI on each
// `WORLD_MAP_UPDATE`, which is how one map click came to cost 57 ms.
//
// Misses are cached too, as the size probe's are: a path that does not resolve is the common
// case for an addon probing for optional art, and it is the one that pays for the directory
// walk.
let seen_textures: std::cell::RefCell<std::collections::HashMap<String, bool>> =
std::cell::RefCell::new(std::collections::HashMap::new());
script.set_texture_probe(Box::new(move |path| {
benilla_assets::sprite_candidates(path).iter().any(|c| {
// Keyed by the path exactly as the region carries it, so a hit allocates nothing; two
// spellings of one file get two entries, which is cheaper than normalising to find out.
if let Some(&cached) = seen_textures.borrow().get(path) {
return cached;
}
let found = benilla_assets::sprite_candidates(path).iter().any(|c| {
chain.lock_recover().contains(c)
|| root
.as_deref()
.is_some_and(|r| benilla_assets::loose_addon_file(r, c).is_some())
})
});
seen_textures.borrow_mut().insert(path.to_string(), found);
found
}));
// Keyed by the path exactly as the region carries it, so a hit allocates nothing (this is
// asked per zero-size textured region per layout pass); misses are cached too.