From cd6731799b231a14421ea5fd67bd886b9c0d415a Mon Sep 17 00:00:00 2001 From: grom Date: Wed, 21 Oct 2026 17:47:07 -0600 Subject: [PATCH] ui: memoise the texture-existence probe, as its two neighbours already are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- crates/benilla-app/src/ui_script/lifecycle.rs | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index ab927b7d..a8c40d68 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -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::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.