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.